Forward-looking plan for items that were intentionally deferred during the multi-phase
refactor that extracted helpers from packages/doenetml-worker-javascript/src/Core.js
(now Core.ts) into src/core/*.ts. PRs #1036–#1056 closed the large structural
work; what remains is a mix of (a) typing tightening blocked on upstream type work,
(b) behaviour fixes in untested error paths, (c) a benchmarking-gated unification, and
(d) old TODO/XXX markers lifted verbatim from Core.js that were never refactor work
in the first place.
Each section below lists the scope, blocker (why it isn't done yet), and strategy (concrete next step when someone picks it up).
Scope. Several inner positions in src/core/StateVariableDefinitionFactory.ts
remain any:
componentClass: any,redefineDependencies: any,targetComponent/adapterTargetComponent: anyon the public functions.attributeSpecification: anyin the four_*Attribute*helpers.- The internal callbacks attached to
stateDef(function ({ dependencyValues, usedDefault, essentialValues }) { … }) carry ~20 implicit-any errors.
Blocker. Tightening requires upstream type work that is bigger than this file:
- A real type for the component-class shape (
createAttributesObject,returnNormalizedStateVariableDefinitions,primaryStateVariableForDefinition,implicitPropReturnsSameType). - A
RedefineDependenciesdiscriminated union ({ linkSource: "adapter" | "referenceShadow"; … }). - Growing
AttributeDefinition<unknown>(inutils/dast/types.ts) to cover the fields the runtime additionally reads:noInverse,componentStateVariableForAttributeValue,fallBackToSourceCompositeStateVariable,essentialVarName,isLocation, and the inverse-path fields. - For the
stateDefcallbacks: importing/exporting the dep-value/usedDefault/ essentialValues bag types fromStateVariableEvaluator.
Strategy. Sequence the upstream work first:
- Define
ComponentClassShapeinsrc/types/(or extend the existingcomponentInstance.ts). - Define
RedefineDependenciesas a discriminated union. - Extend
AttributeDefinitionwith the missing fields. - Then thread the new types through
StateVariableDefinitionFactory's public functions and the four_*Attribute*helpers. - For the
stateDefcallbacks, factor the bag types out ofStateVariableEvaluatorinto a shared types module and tighten in one pass.
A cheap interim fix worth doing alone: add explicit (args: any) annotations on
the stateDef callbacks to silence the ~20 implicit-any errors without changing
semantics. That brings the file's tsc error count down and makes the real
tightening work easier to read against later.
Scope. Several arrow-callback parameters in
src/core/CompositeReplacementUpdater.ts carry an explicit (repl: any)
annotation (around the component.replacements!.map(...) calls and the
(x: unknown) => !x filter in EssentialValueWriter). They survive only because
ComponentInstance.replacements is loosely typed as any[], with a ! non-null
assertion at each use site.
Blocker. Tightening ComponentInstance.replacements to
(ComponentInstance | string)[] (and dep.downstreamPrimitives to a concrete
shape) is the prerequisite. Doing it scoped just to this file would create
churn that conflicts with the upstream typing pass.
Strategy. Land this together with the wider ComponentInstance typing pass
(item #1's first prerequisite). Once replacements is tightened, the (repl: any)
annotations and most of the ! non-null assertions in this file drop without
behaviour change.
Scope. Inside createReferenceShadowStateVariableDefinitions, the same loop
appears twice:
for (let varName in targetComponent.state) {
let stateObj = targetComponent.state[varName];
if (stateObj.shadowVariable || stateObj.isShadow) {
stateVariablesToShadow.push(varName);
}
}at the prop-variable branch (~lines 650–655) and again at the no-prop-variable branch (~lines 733–738).
Blocker. None — this is a 1-line collapse per call site. Left as the third item of "Further de-duplication" because it pre-dates the class→module conversion and was scoped out of the de-dup PR (#1056) which only touched items #1 and #2.
Strategy. Lift into _collectShadowVariableNames(targetComponent) returning
a string[]; both call sites become stateVariablesToShadow.push(... _collectShadowVariableNames(targetComponent)). ~15 lines of code change,
behaviour-preserving.
Scope. Two windows in src/core/CompositeExpander.ts where expansion-tracking
state is mutated before async work that may throw:
- Parent's unexpanded-composite lists (
expandCompositeComponent, around line 315). The composite is removed fromparent.unexpandedCompositesReady/unexpandedCompositesNotReadybeforecreateSerializedReplacementsand the rest of the async expansion run. If a laterawaitthrows, the parent's lists no longer reflect that the child still needs expansion, and dependency code that consults those lists (Dependencies.js:5208,5212,5281) will misclassify the parent. compositesBeingExpandedpush/pop pairing. The push happens at the top ofexpandCompositeComponent(line 313); the matching pop happens in either_finishExpanding(line 425)(non-shadow path) or insideexpandShadowingCompositeat_finishExpanding(line 704)(shadow path). Any throw between push and pop leaks the entry permanently.Dependencies.resolveItem()and the circular-shadow checks both consult this array, so a single failed expansion can cause later updates to be misclassified as circular or in-progress until the worker is recreated.
Blocker. Behaviour change for an error path with no forced-throw test. The
only existing test that touches this code (functionTag.test.ts:7319) is a
happy-path regression for an error that was reachable, not a forced-throw
test. Without coverage, it's impossible to verify the fix doesn't introduce a
new failure mode.
Strategy.
- First write a Vitest test that intentionally triggers an expansion failure
(e.g. a composite whose
createSerializedReplacementsthrows via a maliciously-shaped DoenetML), and asserts that subsequent unrelated expansions still succeed (proves the leak). - Then in
expandCompositeComponent, wrap the body after the push (line 313) intry/finallythat owns thecompositesBeingExpandedpop via an idempotentindexOf+splice(so it's safe even if the dispatch intoexpandShadowingComposite's own_finishExpandingalready popped on the success path). - Defer the parent-list splice (lines 315–328) until after the awaited work
that can throw — i.e. either move it after the
await createAndSetReplacementsor capture the splice positions and restore them in thecatchbranch. - Remove the inline
_finishExpandingcalls at lines 425 and 704 once thetry/finallyowns the cleanup. - Run the new test plus the existing
functionTag.test.tsregression to confirm the happy path is unchanged.
Scope. src/core/StateVariableInitializer.ts:449–797 — the if (stateVarObj.numDimensions > 1) { ... } else { ... } block — duplicates the
per-array plumbing across both branches: keyToIndex, setArrayValue,
getArrayValue, getAllArrayKeys, arrayVarNameFromArrayKey,
arrayVarNameFromPropIndex, and adjustArrayToNewArraySize. The 1-D branch is
essentially the N-D branch specialised to numDimensions === 1.
A unified implementation that always uses the multi-index path would shed roughly 80 lines of duplication.
Blocker. The 1-D path is hot and has a flatter, faster shape (Number(key)
instead of key.split(",").map(Number)). Unifying without measuring the
performance impact on array-heavy components risks a silent regression on the
common case.
Strategy.
- Benchmark the 1-D path against the existing array-heavy components
(
mathList,numberList,point's array entries) under realistic document sizes. The package has no existing benchmark harness — this likely means setting up a small Vitestbenchfile or using the existingcreateTestCorewithperformance.now()brackets. - If the perf delta is negligible (<5%), unify on the N-D path and delete the 1-D specialisation.
- If the delta is meaningful, keep both branches but extract the shared plumbing (the seven functions above) into a helper that both branches instantiate, parameterised by the index-decoder function.
Scope. Three sites in src/core/StateVariableInitializer.ts open with
if (args.arrayKeys === undefined) { args.arrayKeys = stateVarObj.getAllArrayKeys(args.arraySize); }.
Blocker. Status: deferred indefinitely. Originally proposed as "fold into
getAllArrayKeys itself", but that doesn't compose — getAllArrayKeys takes
arraySize, not arrayKeys, so the fold isn't actually possible. A small
mutating helper _resolveArrayKeysOnArgs(args, stateVarObj) saves ~6 net lines
and adds an indirection. ??= would also work but subtly changes the
null/undefined semantics.
Strategy. Don't pursue as standalone work. If the file is opened for the 1-D vs N-D unification (item #5), fold the defaulting into a single helper during that pass while everything is being moved around anyway.
Scope. Phase 3 hit two regressions of the same shape (fixed in caf3033f5)
that any future mechanical extraction from Core will hit again. This is a
checklist to run when reviewing the next extraction PR, not active code work.
Blocker. None — this isn't a fix, it's review guidance.
Strategy. Move into AGENTS.md (or a sibling reviewer's-checklist file) so
it surfaces during PR review of future Core extractions, then remove from this
plan. The checklist itself:
let core = this;captures. Inside the original Core method,thisis Core. Inside the extracted manager method,thisis the manager — so the line must becomelet core = this.core;. A blind copy silently changes which objectcorerefers to.function () {}callbacks attached to plain objects (e.g.,stateDef.definition = function (args) { ... this.X(...) ... }). These are call-site-bound by whatever later invokes them — typically Core or a state-var machinery object — sothisinside the body refers to that call-site object, not the surrounding lexical scope. After extraction, if the body usesthis.X()expecting Core, it still works (the call site is unchanged). But if the body was rewritten to usethis.core.X()during extraction (assuming "this is the manager"), it breaks. Leave these bodies alone — letthisresolve at the call site..bind(this)on wrappers passed by reference (e.g.,core.foo.bind(this)). After extraction,thisis the manager; the bind target needs to becomethis.coreexplicitly.
Quick grep targets when reviewing the next phase: let core = this,
\.bind(this), and arrow vs. function callback choices on objects assigned
to stateDef / stateVarObj.
Scope. Markers lifted verbatim from Core.js into the extracted modules
during Phases 2–4. None are blockers — they're unanswered design questions
that have been deferred for years.
Blocker. Most of these aren't refactor work; they're domain-design questions whose answers depend on understanding the original intent. Tackling them is independent of the structural refactor.
Strategy. Don't address as a batch. Each marker should be revisited the
next time someone genuinely needs to change behaviour in that area —
debugging, feature work, or tracing an unrelated bug. The canonical lookup is
grep -nE "TODO|XXX|kludge" src/core/<file> since line numbers drift.
The current inventory (for awareness, not as a punch list):
Phase 2 modules:
ProcessQueue.ts(~lines 97, 106) — "if skip an update, presumably we should call reject???" — skipped queue entries currently never resolve or reject, so callers awaiting them hang. Also a commented-outgetStateVariableValuesqueue branch betweenupdateandaction— revive or delete.RendererInstructionBuilder.ts(~lines 91, 253, 275) — change-detection pass design questions; deleted-and-recreated-with-same-name guard;rendererTypecapture position.ChildMatcher.ts(~lines 430, 468) — placeholder-adapter vs new componentIdx approach.
Phase 3 modules:
StateVariableInitializer.ts(~lines 42, 313, 362, 666, 766, 817, 868, 923, 1449) — alias-handling, array-entry size, dependency-name punning, array-entry materialization, public-state-variable-name resolution.StateVariableDefinitionFactory.ts(~lines 1355, 1451) — array-entry definition setup ("how do we make it do this just once?").ComponentBuilder.ts(~lines 170, 238) — post-creation child-result re-derivation.CompositeExpander.ts(~line 373) —createSerializedReplacementsretry loop infinite-loop concern.
Phase 4 modules:
EssentialValueWriter.ts—executeUpdateStateVariablespost-flush composite-expand re-check;requestComponentChangesadditionalStateVariableValuesguarding; the throw branch in the primitive-child path.StateVariableEvaluator.ts— thereprocessAfterEvaluate"kludge" comment; shallow-array-equality check depth;noChanges+unresolved-variable response; multidimensional-array handling.StalenessPropagator.ts— decade-old "remove all these error checks to speed up process" marker inprocessMarkStale's validation block.CompositeReplacementUpdater.ts—updateCompositeReplacementscarries several open questions (evaluate-vs-resolve, infinite-loop, downstream dependency removal,replacementOfnecessity incalculateAllComponentsShadowing).
Scope. The else branch of _buildAttributeValueDependency in
src/core/StateVariableDefinitionFactory.ts emits:
return {
attributeComponent: {
dependencyType: "attributeComponent",
attributeName: attrName,
variableNames: [stateVariableForAttributeValue],
},
};stateVariableForAttributeValue comes from _resolveAttributeValueVariable,
which returns undefined when attributeSpecification.createComponentOfType
is absent. If a spec reaches the else branch (no createPrimitiveOfType,
no createReferences) without createComponentOfType, the emitted dependency
carries variableNames: [undefined], which the dependency system would
receive silently.
This was the pre-existing behaviour at both original call sites before the
helper was extracted in PR #1056; the refactor correctly preserved it. The
question is whether the combination — else-branch spec with no
createComponentOfType — is actually reachable in practice or is already
excluded upstream by how createAttributeStateVariableDefinitions and
createReferenceShadowStateVariableDefinitions are called.
Blocker. No concrete repro; unclear if reachable.
Strategy.
- When the
AttributeDefinitiontyping pass (item #1, step 3) lands, addcreateComponentOfTypeto the type and check whether the type system statically rules out the problematic combination. If it does, the issue is moot. - If the combination is reachable (a spec with neither flag nor
createComponentOfType), add a guard in_buildAttributeValueDependencythat throws a descriptive error rather than silently passing[undefined].
Scope. _resolvePrimaryStateVariableForDefinition is called with
throwIfMissing: false from createAdapterStateVariableDefinitions. When the
primary state variable name does not exist in stateVariableDefinitions, the
helper returns { name, stateDef: undefined }. The caller immediately does
stateDef.isShadow = true, producing a bare TypeError: Cannot set properties of undefined at that line — the same crash point and error quality as before
the helper was introduced (the original code had an identical assignment on the
next line after the local lookup). No regression.
Blocker. None — this is a future quality-of-life improvement, not a fix.
Strategy. When the typing pass (item #1) reaches componentClass and
redefineDependencies, revisit the adapter path: add an explicit if (!stateDef) guard with a descriptive throw analogous to the
throwIfMissing: true messages, so a malformed component class surfaces a
clear error instead of a null-access crash.
- Cypress runs in CI — do not include local Cypress steps in any verification plan.
- AGENTS.md is the source of truth for project conventions; CLAUDE.md is a one-line pointer.
- Item dependencies. #2 (
(repl: any)callbacks) is blocked on the same upstreamComponentInstancework that #1 needs. Sequence them together. #5 (1-D vs N-D) and #6 (getAllArrayKeysdefaulting) touch the same file — fold #6 in if #5 is being done. #9 (variableNames: [undefined]) and #10 (adapter error quality) both become easier to close out once theAttributeDefinitionandcomponentClasstyping in #1 lands — check them during that pass. Everything else is independent. - Items intentionally out of this plan. Wider Core typing (converting
ComponentInstance.state,dependencies, etc. to concrete types) is its own initiative, not refactor follow-up. The TODO inventory in #8 is awareness, not work.