Skip to content
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

Refactor: engine <-> client interface implementation #67

Merged
merged 87 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
daafd95
DX improvement: ESLint recognizes JSDoc type references
eyelidlessness Mar 18, 2024
526d084
Update existing READMEs to reflect changes to GitHub’s special note b…
eyelidlessness Mar 12, 2024
3345ca8
Initial engine/client interface: types, explainer README, doc gen script
eyelidlessness Mar 12, 2024
538e37b
Expand client interface resource types
eyelidlessness Mar 12, 2024
bf2c2de
Add compatibility for reading text from Blob values
eyelidlessness Mar 12, 2024
1f13ae4
Engine-client implementation: (XML) resource retrieval
eyelidlessness Mar 12, 2024
5d0798b
Minimal internal reactivity implementation, sutiable for testing…
eyelidlessness Mar 16, 2024
0f7c0b7
Minor client API refinements: select options, relax opaque reactive o…
eyelidlessness Mar 18, 2024
74ad508
Initial structure of engine implementation of client interface
eyelidlessness Mar 28, 2024
e000288
All nodes get a unique (per session at least) id
eyelidlessness Mar 20, 2024
5c88575
Initial support for engine/client shared state
eyelidlessness Mar 28, 2024
5ae3bda
Implement most `Root`/`RootNode` functionality
eyelidlessness Mar 28, 2024
fd8eace
First pass making all node implementations non-abstract
eyelidlessness Mar 28, 2024
ca7e9a3
First pass, build the initial instance tree
eyelidlessness Mar 28, 2024
87d2352
Expose new client interface/implementation …
eyelidlessness Mar 28, 2024
3062bc5
WIP: Update ui-solid to use new client interface
eyelidlessness Mar 28, 2024
66f5340
Fix: ui-solid/xforms-engine reactivity propagation
eyelidlessness Mar 28, 2024
eda41a7
(Temporarily?) ui-solid uses xforms-engine source
eyelidlessness Mar 22, 2024
d83435e
Introduce general concept of shared state initialization
eyelidlessness Mar 28, 2024
0b89575
Generalized API for reactive `DependentExpression`s
eyelidlessness Mar 29, 2024
60411c5
Bind computations: `readonly`, `relevant`, `required`
eyelidlessness Mar 29, 2024
432512c
Initial implementation of value state, StringField value
eyelidlessness Mar 29, 2024
355f232
Prevent writes to readonly fields
eyelidlessness Mar 31, 2024
a856c6d
Set non-relevant value state to blank…
eyelidlessness Mar 31, 2024
28f2e01
Implement general solution for reactive text, labels, hints
eyelidlessness Mar 29, 2024
e0200c1
Wire up reactive Group labels, StringField labels/hints
eyelidlessness Mar 29, 2024
752aaf2
Initial implementation of select items
eyelidlessness Mar 29, 2024
107f35c
Initial support for SelectField value writes
eyelidlessness Mar 29, 2024
7c802be
Initial SelectField `subscribe` implementation
eyelidlessness Mar 29, 2024
86362d5
Support for adding and removing repeat instances
eyelidlessness Mar 30, 2024
3690e36
Provide label state for repeat instancres
eyelidlessness Mar 31, 2024
d308b58
Make the new instance state implementation reactive!
eyelidlessness Mar 31, 2024
7d7ef17
Where we’re going, we don’t need (manual) toposort!
eyelidlessness Mar 31, 2024
a98dc4e
CI side quest: ui-solid browser test tree-sitter-xpath failure
eyelidlessness Apr 1, 2024
6b83201
Eliminate Solid client state special case…
eyelidlessness Apr 2, 2024
8b46fcd
Map `children` state by node ID
eyelidlessness Apr 2, 2024
f174f61
Client API cleanup: remove unused type from hierarchy
eyelidlessness Apr 3, 2024
89580e3
Prepare to migrate EntryState tests to new client interface
eyelidlessness Apr 3, 2024
0429f95
Fix: `childrenState` must be defined before other reactive node state…
eyelidlessness Apr 3, 2024
c92188c
Fix: reactivity of label/hint outputs and translations
eyelidlessness Apr 3, 2024
9e04a53
Fix: client interface `StringNode` specify its `currentState` type!
eyelidlessness Apr 3, 2024
fbd70f2
Create scenario package for ported JavaRosa tests
eyelidlessness Apr 4, 2024
0d3cae8
Migrate `Scenario` to new client interface, minor test updates for async
eyelidlessness Apr 4, 2024
6b8330b
This `createRoot` is now superfluous
eyelidlessness Apr 4, 2024
39e5355
Add passing relative version of currently failing repeat test
eyelidlessness Apr 4, 2024
c212529
Update ui-solid translations test to new client interface
eyelidlessness Apr 4, 2024
edf4ebc
Update subset of ui-solid XFormDetails component…
eyelidlessness Apr 4, 2024
5d2c030
Remove xforms-engine exports of `EntryState` APIs…
eyelidlessness Apr 4, 2024
0dfd1be
Relax internal reactivity types
eyelidlessness Apr 4, 2024
48d12a5
Prepare to migrate remaining EntryState tests to new instance impleme…
eyelidlessness Apr 4, 2024
25eedb5
Migrate remaining EntryState tests to new client API
eyelidlessness Apr 4, 2024
8b9b45c
Mark intentional failure for change to recalculate re-relevant…
eyelidlessness Apr 4, 2024
421ffd9
Remove final remnants of `EntryState`
eyelidlessness Apr 4, 2024
4c13a87
Remove reactive libs no longer in use
eyelidlessness Apr 4, 2024
6c0f03b
Remove dependencies no longer in use
eyelidlessness Apr 4, 2024
6433974
Fix: don’t try to call fake XPath expression for static text in labels
eyelidlessness Apr 5, 2024
0a4677a
Fix: include label on `RepeatRangeNode`/`RepeatRange`
eyelidlessness Apr 5, 2024
d38b509
ui-solid: render label of repeat range when repeat instance has no la…
eyelidlessness Apr 5, 2024
0d4931f
Expose `AnyLeafNode` in client interface …
eyelidlessness Apr 5, 2024
56246c5
Move xforms-engine test files out of src …
eyelidlessness Apr 9, 2024
a043f42
Remove `engineConfig` from client `*Node` interfaces
eyelidlessness Apr 9, 2024
f2fe779
Remove @todo on client interface `activeLanguage` JSDoc…
eyelidlessness Apr 9, 2024
a6e2e5f
Add JSDoc example for `SubtreeNode`
eyelidlessness Apr 9, 2024
3f4cb19
Remove apparently-unnecessary try/catch in `createUniqueId`
eyelidlessness Apr 9, 2024
4f7c37d
Eliminate unnecessary type indirection for `getInstanceConfig`
eyelidlessness Apr 9, 2024
6f05051
Narrow `initializeForm` type to client interface in build…
eyelidlessness Apr 9, 2024
a2ce805
Trim string input to `retrieveSourceXMLResource`
eyelidlessness Apr 9, 2024
caa4b44
Fix: assignability of Vue’s `reactive` to factory type
eyelidlessness Apr 10, 2024
9625f7b
Improve readability of inserting `RepeatInstance` in `addInstances`
eyelidlessness Apr 10, 2024
202f220
Make InstanceNode.engineConfig a constructor assignment
eyelidlessness Apr 10, 2024
2b51442
Add JSDoc and `@package` access scope to `InstanceNode.getChildren`.
eyelidlessness Apr 10, 2024
b85a254
`nodeType` (all nodes) and `nodeVariant` (node-specific)…
eyelidlessness Apr 10, 2024
5eab1fe
Simplify `Scenario` type refinements by narrowing with `nodeType`
eyelidlessness Apr 10, 2024
8ef9bef
Simplify ui-solid type refinements, prepare for further improvement…
eyelidlessness Apr 10, 2024
3f85bec
Throw when children state and their NodeID[] representations diverge
eyelidlessness Apr 10, 2024
9657164
Simplify compute -> set `RepeatInstance` index state
eyelidlessness Apr 10, 2024
d3a60a6
Move constant expression memo into reactive scope task
eyelidlessness Apr 10, 2024
fb13e03
Call value setter with object rather than callback
eyelidlessness Apr 10, 2024
95641c5
Roll back addition of `nodeVariant` property for now
eyelidlessness Apr 11, 2024
849ae5d
Remove wrong (and typo) condition around `currentState` write failure…
eyelidlessness Apr 11, 2024
725a74e
Move remaining .test.ts(x) files src -> test (parallel FS structure)
eyelidlessness Apr 11, 2024
a687791
Remove unnecessary optional access on recursive call to internal `get…
eyelidlessness Apr 11, 2024
7a465bd
Repeat instance removal of `contextNode` is special case
eyelidlessness Apr 11, 2024
51f1a05
Remove `InstanceNodeState` and `DescendantNodeState`
eyelidlessness Apr 11, 2024
9a4b937
Generalize `insertAtIndex` as `common` package lib
eyelidlessness Apr 11, 2024
a84c386
Generalize `identity` function as `common` package lib
eyelidlessness Apr 11, 2024
81eeae1
Use`identity` lib function for `StringField` encodeValue/decodeValue …
eyelidlessness Apr 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Initial support for engine/client shared state
This is based on the recognition that the pertinent Solid reactive primitives align closely with the semantics of standard JS property descriptors. Ultimately, what we end up with is:

- Each node’s state is “specified” by an object defining the state’s property structure
- Each state property is either:
  - mutable: specified with a signal-like value, mapped to a get/set property backed by that signal-like value
  - computed: specified with a thunk/accessor/memo-like function, mapped to a get property backed by that function
  - static: specified with any *other* value, mapped to a non-`writable` property assigned that value
- Each node’s state (internal) *types* are derived with the same mapping logic

The appropriate derived state types will in turn be checked for **assignability** to their corresponding client-facing `node.currentState`.

- - -

This commit is a second iteration on this set of functionality. The earlier effort (now rebased out of this branch) produced objects with roughly the same characteristics. Where it differed:

- The input types were much less ergonomic in actual usage: generally speaking, each node had to perform its own version of the state/property descriptor mappings described above.
- Despite doing less work, the actual state initialization implementation was more complex because it was introspecting on property descriptors themselves rather than on simpler signal-like and thunk types.
  • Loading branch information
eyelidlessness committed Mar 28, 2024
commit 5c88575d7d63e981fe1182cf93d377f0e4409e21
23 changes: 23 additions & 0 deletions packages/common/src/lib/objects/structure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export type PropertyKeys<T> = ReadonlyArray<string & keyof T>;

export const getPropertyKeys = <T extends object>(object: T): PropertyKeys<T> => {
return Object.keys(object) as Array<string & keyof T>;
};

export type PropertyDescriptorEntry<T> = readonly [string & keyof T, PropertyDescriptor];

export type PropertyDescriptors<T> = Array<PropertyDescriptorEntry<T>>;

export const getPropertyDescriptors = <T extends object>(object: T): PropertyDescriptors<T> => {
const keys = getPropertyKeys(object);

return keys.map((key) => {
const descriptor = Object.getOwnPropertyDescriptor(object, key);

if (descriptor == null) {
throw new Error(`Could not get property descriptor for key ${key}`);
}

return [key, descriptor];
});
};
12 changes: 12 additions & 0 deletions packages/common/types/helpers.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,15 @@ export type Merge<T> = Identity<{
* in-editor documentation.
*/
export type ExpandUnion<T> = Exclude<T, never>;

/**
* Maps an object type to a shallowly-mutable type otherwise of the same shape
* and type.
*
* {@link T} should be a {@link Record}-like object. This type is **NOT
* SUITABLE** for producing mutable versions of built-in collections like
* `readonly T[]` -> `T[]` or `ReadonlyMap<T>` -> `Map<T>`.
*/
type ShallowMutable<T extends object> = {
-readonly [K in keyof T]: T[K];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { getPropertyKeys } from '@odk-web-forms/common/lib/objects/structure.ts';
import type { ShallowMutable } from '@odk-web-forms/common/types/helpers.js';
import { createComputed, untrack } from 'solid-js';
import { createMutable } from 'solid-js/store';
import type { ReactiveScope } from '../scope.ts';
import type { EngineState } from './createEngineState.ts';
import type { SpecifiedState, StateSpec } from './createSpecifiedState.ts';
import type { InternalClientRepresentation } from './representations.ts';
import { declareInternalClientRepresentation } from './representations.ts';

const deriveInitialState = <Spec extends StateSpec>(
scope: ReactiveScope,
engineState: EngineState<Spec>
): ShallowMutable<SpecifiedState<Spec>> => {
return scope.runTask(() => {
return untrack(() => {
return { ...engineState };
});
});
};

export type SpecifiedClientStateFactory<Spec extends StateSpec> = (
input: ShallowMutable<SpecifiedState<Spec>>
) => ShallowMutable<SpecifiedState<Spec>>;

export type ClientState<Spec extends StateSpec> = InternalClientRepresentation<
SpecifiedState<Spec>
>;

export const createClientState = <Spec extends StateSpec>(
scope: ReactiveScope,
engineState: EngineState<Spec>,
clientStateFactory: SpecifiedClientStateFactory<Spec>
): ClientState<Spec> => {
// Special case: if we **know** the client is Solid, we also know that the
// engine state is already reactive for the client. In which case, we can
// skip the client mutable state wrapper and just rely on the fact that the
// client-facing `currentState` will still be wrapped in a read-only proxy.
if (clientStateFactory === createMutable) {
return declareInternalClientRepresentation<ShallowMutable<SpecifiedState<Spec>>>(engineState);
}

const initialState = deriveInitialState(scope, engineState);
const clientState = clientStateFactory(initialState);

scope.runTask(() => {
getPropertyKeys(initialState).forEach((key) => {
createComputed(() => {
clientState[key] = engineState[key];
});
});
});

return declareInternalClientRepresentation(clientState);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ReactiveScope } from '../scope.ts';
import type { ClientState } from './createClientState.ts';
import type { SpecifiedState, StateSpec } from './createSpecifiedState.ts';
import type { ReadonlyClientRepresentation } from './representations.ts';
import { declareReadonlyClientRepresentation } from './representations.ts';

export type CurrentState<Spec extends StateSpec> = ReadonlyClientRepresentation<
SpecifiedState<Spec>
>;

export const createCurrentState = <Spec extends StateSpec>(
scope: ReactiveScope,
clientState: ClientState<Spec>
): CurrentState<Spec> => {
return scope.runTask(() => {
const currentStateProxy = new Proxy<Readonly<SpecifiedState<Spec>>>(clientState, {
get: (_, key) => {
return clientState[key as keyof SpecifiedState<Spec>];
},
set: () => {
throw new Error('Cannot write directly to client-facing currentState');
},
});

return declareReadonlyClientRepresentation(currentStateProxy);
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ReactiveScope } from '../scope.ts';
import type { SpecifiedState, StateSpec } from './createSpecifiedState.ts';
import { createSpecifiedState } from './createSpecifiedState.ts';
import type { EngineRepresentation } from './representations.ts';
import { declareEngineRepresentation } from './representations.ts';

export type EngineState<Spec extends StateSpec> = EngineRepresentation<SpecifiedState<Spec>>;

export const createEngineState = <Spec extends StateSpec>(
scope: ReactiveScope,
spec: Spec
): EngineState<Spec> => {
return scope.runTask(() => {
const state = createSpecifiedState(spec);

return declareEngineRepresentation(state);
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { getPropertyKeys } from '@odk-web-forms/common/lib/objects/structure.ts';
import type { ReactiveScope } from '../scope.ts';
import type { ClientState, SpecifiedClientStateFactory } from './createClientState.ts';
import { createClientState } from './createClientState.ts';
import type { CurrentState } from './createCurrentState.ts';
import { createCurrentState } from './createCurrentState.ts';
import type { EngineState } from './createEngineState.ts';
import { createEngineState } from './createEngineState.ts';
import type { MutablePropertySpec, SpecifiedState, StateSpec } from './createSpecifiedState.ts';
import { isComputedPropertySpec, isMutablePropertySpec } from './createSpecifiedState.ts';

// prettier-ignore
type MutableKeyOf<Spec extends StateSpec> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[K in string & keyof Spec]: Spec[K] extends MutablePropertySpec<any>
? K
: never;
}[string & keyof Spec];

type SetEnginePropertyState<Spec extends StateSpec> = <K extends MutableKeyOf<Spec>>(
key: K,
newValue: SpecifiedState<Spec>[K]
) => SpecifiedState<Spec>[K];

export interface SharedNodeState<Spec extends StateSpec> {
readonly spec: Spec;
readonly engineState: EngineState<Spec>;
readonly clientState: ClientState<Spec>;
readonly currentState: CurrentState<Spec>;
readonly setProperty: SetEnginePropertyState<Spec>;
}

interface SharedNodeStateOptions<Spec extends StateSpec> {
readonly clientStateFactory: SpecifiedClientStateFactory<Spec>;
}

export const createSharedNodeState = <Spec extends StateSpec>(
scope: ReactiveScope,
spec: Spec,
options: SharedNodeStateOptions<Spec>
): SharedNodeState<Spec> => {
const engineState = createEngineState(scope, spec);
const clientState = createClientState(scope, engineState, options.clientStateFactory);
const currentState = createCurrentState(scope, clientState);

const specKeys = getPropertyKeys(spec);
const mutableKeys = specKeys.filter((key) => {
return isMutablePropertySpec(spec[key]);
});
const computedKeys = specKeys.filter((key) => {
return isComputedPropertySpec(spec[key]);
});

const setProperty: SetEnginePropertyState<Spec> = (key, value) => {
if (!mutableKeys.includes(key)) {
const specType = computedKeys.includes(key) ? 'computed' : 'static';
throw new TypeError(`Cannot write to '${key}': property is ${specType}`);
}

return scope.runTask(() => {
return (engineState[key] = value);
});
};

return {
spec,
engineState,
clientState,
currentState,
setProperty,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { UnreachableError } from '@odk-web-forms/common/lib/error/UnreachableError.ts';
import type {
ComputedPropertySpec,
MutablePropertySpec,
StatePropertySpec,
StaticPropertySpec,
} from './createSpecifiedState.ts';
import {
isComputedPropertySpec,
isMutablePropertySpec,
isStaticPropertySpec,
} from './createSpecifiedState.ts';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface SpecifiedPropertyDescriptor<T = any> extends TypedPropertyDescriptor<T> {
readonly configurable: true;
readonly enumerable: true;
}

interface MutableDescriptor<T> extends SpecifiedPropertyDescriptor<T> {
readonly get: () => T;
readonly set: (newValue: T) => void;
readonly writable?: never;
readonly value?: never;
}

const mutableDesciptor = <T>(propertySpec: MutablePropertySpec<T>): MutableDescriptor<T> => {
const [get, set] = propertySpec;

return {
configurable: true,
enumerable: true,
get,
set,
};
};

interface ComputedDescriptor<T> extends SpecifiedPropertyDescriptor<T> {
readonly get: () => T;
readonly set?: never;
readonly writable?: never;
readonly value?: never;
}

const computedDescriptor = <T>(propertySpec: ComputedPropertySpec<T>): ComputedDescriptor<T> => {
return {
configurable: true,
enumerable: true,
get: propertySpec,
};
};

interface StaticDescriptor<T> extends SpecifiedPropertyDescriptor<T> {
readonly get?: never;
readonly set?: never;
readonly writable: false;
readonly value: T;
}

const staticDescriptor = <T>(propertySpec: StaticPropertySpec<T>): StaticDescriptor<T> => {
return {
configurable: true,
enumerable: true,
writable: false,
value: propertySpec,
};
};

export const createSpecifiedPropertyDescriptor = <T>(
propertySpec: StatePropertySpec<T>
): SpecifiedPropertyDescriptor<T> => {
if (isMutablePropertySpec(propertySpec)) {
return mutableDesciptor(propertySpec);
}

if (isComputedPropertySpec(propertySpec)) {
return computedDescriptor(propertySpec);
}

if (isStaticPropertySpec(propertySpec)) {
return staticDescriptor(propertySpec);
}

throw new UnreachableError(propertySpec);
};
Loading