Repository navigation
✨ node data bag + Context accessors with prototype inheritance #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export {}; | ||
| export * from './core/context.ts'; | ||
| export * from './core/node.ts'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| import { describe, expect, test } from 'vitest'; | ||
| import { createContext, MissingContextError } from './context.ts'; | ||
| import { Node } from './node.ts'; | ||
|
|
||
| const theme = createContext<string>('theme'); | ||
| const count = createContext<number>('count'); | ||
| const locale = createContext<string>('locale', 'en'); | ||
|
|
||
| describe('context', () => { | ||
| describe('on a single node', () => { | ||
| test('get returns undefined for a context that was never set', () => { | ||
| expect(theme.get(new Node())).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('does not inherit values from Object.prototype', () => { | ||
| const constructor = createContext('constructor'); | ||
|
|
||
| expect(constructor.get(new Node())).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('get returns the value after it is set', () => { | ||
| const n = new Node(); | ||
| theme.set(n, 'dark'); | ||
| expect(theme.get(n)).toBe('dark'); | ||
| }); | ||
|
|
||
| test('the last set wins', () => { | ||
| const n = new Node(); | ||
| theme.set(n, 'dark'); | ||
| theme.set(n, 'light'); | ||
| expect(theme.get(n)).toBe('light'); | ||
| }); | ||
|
|
||
| test('keeps falsy values — 0 is stored, not treated as unset', () => { | ||
| const n = new Node(); | ||
| count.set(n, 0); | ||
| expect(count.get(n)).toBe(0); | ||
| }); | ||
|
|
||
| test('distinct contexts do not collide', () => { | ||
| const n = new Node(); | ||
| theme.set(n, 'dark'); | ||
| expect(count.get(n)).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('contexts with the same name share a value', () => { | ||
| const first = createContext<string>('shared'); | ||
| const second = createContext<string>('shared'); | ||
| const n = new Node(); | ||
| first.set(n, 'value'); | ||
| expect(second.get(n)).toBe('value'); | ||
| }); | ||
|
|
||
| test('expect returns the value when the context is set', () => { | ||
| const n = new Node(); | ||
| theme.set(n, 'dark'); | ||
| expect(theme.expect(n)).toBe('dark'); | ||
| }); | ||
|
|
||
| test('expect throws MissingContextError when unset with no default', () => { | ||
| expect(() => theme.expect(new Node())).toThrow(MissingContextError); | ||
| }); | ||
|
|
||
| test('expect falls back to the default when unset', () => { | ||
| expect(locale.expect(new Node())).toBe('en'); | ||
| }); | ||
|
|
||
| test('a set value takes precedence over the default', () => { | ||
| const n = new Node(); | ||
| locale.set(n, 'fr'); | ||
| expect(locale.expect(n)).toBe('fr'); | ||
| }); | ||
|
|
||
| test('get returns the default when the context is unset', () => { | ||
| expect(locale.get(new Node())).toBe('en'); | ||
| }); | ||
|
|
||
| test('a set value overrides the default for get', () => { | ||
| const n = new Node(); | ||
| locale.set(n, 'fr'); | ||
| expect(locale.get(n)).toBe('fr'); | ||
| }); | ||
|
|
||
| test('hasOwn is false before a value is set', () => { | ||
| expect(theme.hasOwn(new Node())).toBe(false); | ||
| }); | ||
|
|
||
| test('hasOwn is true once a value is set on the node', () => { | ||
| const n = new Node(); | ||
| theme.set(n, 'dark'); | ||
| expect(theme.hasOwn(n)).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('inheritance through the node tree', () => { | ||
| test('a child inherits a value set on its parent', () => { | ||
| const parent = new Node(); | ||
| theme.set(parent, 'dark'); | ||
| expect(theme.get(new Node(parent))).toBe('dark'); | ||
| }); | ||
|
|
||
| test('a value set on a child shadows the parent for that child only', () => { | ||
| const parent = new Node(); | ||
| theme.set(parent, 'dark'); | ||
| const child = new Node(parent); | ||
| theme.set(child, 'light'); | ||
| expect(theme.get(child)).toBe('light'); | ||
| expect(theme.get(parent)).toBe('dark'); | ||
| }); | ||
|
|
||
| test('setting a value on a child does not leak up to the parent', () => { | ||
| const parent = new Node(); | ||
| const child = new Node(parent); | ||
| theme.set(child, 'light'); | ||
| expect(theme.get(parent)).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('expect inherits an ancestor value', () => { | ||
| const parent = new Node(); | ||
| theme.set(parent, 'dark'); | ||
| expect(theme.expect(new Node(parent))).toBe('dark'); | ||
| }); | ||
|
|
||
| test('an inherited value resolves through multiple levels', () => { | ||
| const grand = new Node(); | ||
| theme.set(grand, 'dark'); | ||
| const parent = new Node(grand); | ||
| expect(theme.get(new Node(parent))).toBe('dark'); | ||
| }); | ||
|
|
||
| test('an explicitly set undefined shadows the ancestor value', () => { | ||
| const parent = new Node(); | ||
| locale.set(parent, 'fr'); | ||
| const child = new Node(parent); | ||
| locale.set(child, undefined as unknown as string); | ||
| expect(locale.get(child)).toBe('en'); | ||
| expect(locale.get(parent)).toBe('fr'); | ||
| }); | ||
|
|
||
| test('an explicitly set undefined shadows with no default to fall back to', () => { | ||
| const parent = new Node(); | ||
| theme.set(parent, 'dark'); | ||
| const child = new Node(parent); | ||
| theme.set(child, undefined as unknown as string); | ||
| expect(theme.get(child)).toBeUndefined(); | ||
| expect(() => theme.expect(child)).toThrow(MissingContextError); | ||
| }); | ||
|
|
||
| test('hasOwn is true for an explicitly set undefined', () => { | ||
| const parent = new Node(); | ||
| theme.set(parent, 'dark'); | ||
| const child = new Node(parent); | ||
| theme.set(child, undefined as unknown as string); | ||
| expect(theme.hasOwn(child)).toBe(true); | ||
| }); | ||
|
|
||
| test('hasOwn is false for a value that is only inherited', () => { | ||
| const parent = new Node(); | ||
| theme.set(parent, 'dark'); | ||
| expect(theme.hasOwn(new Node(parent))).toBe(false); | ||
| }); | ||
|
|
||
| test('hasOwn is true only for the node that owns the value', () => { | ||
| const parent = new Node(); | ||
| theme.set(parent, 'dark'); | ||
| const child = new Node(parent); | ||
| theme.set(child, 'light'); | ||
| expect(theme.hasOwn(child)).toBe(true); | ||
| expect(theme.hasOwn(parent)).toBe(true); | ||
| }); | ||
|
|
||
| test('expect falls back to the default when neither node nor ancestors set it', () => { | ||
| const parent = new Node(); | ||
| expect(locale.expect(new Node(parent))).toBe('en'); | ||
| }); | ||
|
|
||
| test('get falls back to the default when neither node nor ancestors set it', () => { | ||
| const parent = new Node(); | ||
| expect(locale.get(new Node(parent))).toBe('en'); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import type { Node } from './node.ts'; | ||
|
|
||
| /** | ||
| * A `Context` defines a value which is in effect for a given {@link Node}. | ||
| * | ||
| * Unless a context value is set for a particular node, it will inherit its | ||
| * value from that node's parent. | ||
| */ | ||
| export class Context<T> { | ||
| /** A unique identifier for this context. */ | ||
| readonly name: string; | ||
|
|
||
| /** The value returned when this context is not present on a node. */ | ||
| readonly defaultValue?: T; | ||
|
|
||
| constructor(name: string, defaultValue?: T) { | ||
| this.name = name; | ||
| this.defaultValue = defaultValue; | ||
| } | ||
|
|
||
| get(node: Node): T | undefined { | ||
| for (let n: Node | undefined = node; n; n = n.parent) { | ||
| const contexts = n.contexts; | ||
| if (contexts?.has(this.name)) { | ||
| const value = contexts.get(this.name) as T | undefined; | ||
| return value === undefined ? this.defaultValue : value; | ||
| } | ||
| } | ||
| return this.defaultValue; | ||
| } | ||
|
|
||
| set(node: Node, value: T): void { | ||
| const contexts = (node.contexts ??= new Map()); | ||
| contexts.set(this.name, value); | ||
| } | ||
|
|
||
| expect(node: Node): T { | ||
| const value = this.get(node); | ||
| if (value === undefined) { | ||
| throw new MissingContextError(this.name); | ||
| } else { | ||
| return value; | ||
| } | ||
| } | ||
|
|
||
| hasOwn(node: Node): boolean { | ||
| return node.contexts?.has(this.name) ?? false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Create a new {@link Context}. | ||
| * | ||
| * @param name - the unique name to give this context. | ||
| * @param defaultValue - the value used when the context is not present on a node. | ||
| * @returns the new context | ||
| */ | ||
| export function createContext<T>(name: string, defaultValue?: T): Context<T> { | ||
| return new Context(name, defaultValue); | ||
| } | ||
|
|
||
| export class MissingContextError extends Error { | ||
| constructor(name: string) { | ||
| super(`no value set for context "${name}"`); | ||
| this.name = 'MissingContextError'; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| export class Node { | ||
| readonly parent?: Node; | ||
| readonly children: Set<Node>; | ||
|
|
||
| contexts?: Map<string, unknown>; | ||
|
|
||
| constructor(parent?: Node) { | ||
| this.parent = parent; | ||
| this.children = new Set(); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.