From 5a8414b1f87402a342923b07d878ed54d2fd1cb5 Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Fri, 1 May 2026 14:14:14 -0500 Subject: [PATCH 1/7] refactor(worker-javascript): extract Phase 1 helpers from Core.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Begin breaking up the 13,837-line Core class by lifting seven self- contained, low-coupling helpers into TypeScript modules. The pattern matches the existing composed siblings (Dependencies.js, ParameterStack): each module is constructed with a `core` back-reference, and Core retains a thin delegating wrapper for every method/property that was previously on the class so external callers (CoreWorker, tests, components, and `coreFunctions`-bound references) continue to work unchanged. Modules extracted: - DiagnosticsManager.ts — diagnostics queue + source-location walk - StateVariableNameResolver.ts — pure-function name resolution utilities - VisibilityTracker.ts — visibility state and save/suspend timers - StatePersistence.ts — save to localStorage / database - AutoSubmitManager.ts — debounced answer-submit queue - NavigationHandler.ts — handleNavigatingToComponent, navigateToTarget - ResolverAdapter.ts — adapter to the external Rust name resolver No behavior change. Core.js drops from 13,837 to 12,909 lines. Co-Authored-By: Claude Haiku 4.5 --- .../src/AutoSubmitManager.ts | 60 + .../doenetml-worker-javascript/src/Core.js | 1162 ++--------------- .../src/DiagnosticsManager.ts | 182 +++ .../src/NavigationHandler.ts | 66 + .../src/ResolverAdapter.ts | 459 +++++++ .../src/StatePersistence.ts | 149 +++ .../src/StateVariableNameResolver.ts | 275 ++++ .../src/VisibilityTracker.ts | 186 +++ 8 files changed, 1494 insertions(+), 1045 deletions(-) create mode 100644 packages/doenetml-worker-javascript/src/AutoSubmitManager.ts create mode 100644 packages/doenetml-worker-javascript/src/DiagnosticsManager.ts create mode 100644 packages/doenetml-worker-javascript/src/NavigationHandler.ts create mode 100644 packages/doenetml-worker-javascript/src/ResolverAdapter.ts create mode 100644 packages/doenetml-worker-javascript/src/StatePersistence.ts create mode 100644 packages/doenetml-worker-javascript/src/StateVariableNameResolver.ts create mode 100644 packages/doenetml-worker-javascript/src/VisibilityTracker.ts diff --git a/packages/doenetml-worker-javascript/src/AutoSubmitManager.ts b/packages/doenetml-worker-javascript/src/AutoSubmitManager.ts new file mode 100644 index 000000000..e2de4e565 --- /dev/null +++ b/packages/doenetml-worker-javascript/src/AutoSubmitManager.ts @@ -0,0 +1,60 @@ +/** + * Owns the debounced auto-submit-answer queue. When state changes record an + * answer that should be submitted, the manager batches them and dispatches + * `submitAnswer` actions after a short debounce. + * + * Holds a back-reference to Core to look up components and dispatch actions. + */ +export class AutoSubmitManager { + core: any; + answersToSubmit: number[]; + submitAnswersTimeout: ReturnType | null; + + constructor({ core }: { core: any }) { + this.core = core; + this.answersToSubmit = []; + this.submitAnswersTimeout = null; + } + + recordAnswer(componentIdx: number): void { + if (!this.answersToSubmit.includes(componentIdx)) { + this.answersToSubmit.push(componentIdx); + } + + if (this.submitAnswersTimeout !== null) { + clearTimeout(this.submitAnswersTimeout); + } + + //Debounce the submit answers + this.submitAnswersTimeout = setTimeout(() => { + this.submitNow(); + }, 1000); + } + + async submitNow(): Promise { + let toSubmit = this.answersToSubmit; + this.answersToSubmit = []; + for (let componentIdx of toSubmit) { + let component = this.core._components[componentIdx]; + + if (component.actions.submitAnswer) { + await this.core.requestAction({ + componentIdx, + actionName: "submitAnswer", + }); + } + } + } + + /** + * Cancel any pending debounce and submit immediately. Used during + * `Core.terminate()` to flush queued answers before shutdown. + */ + async flush(): Promise { + if (this.submitAnswersTimeout !== null) { + clearTimeout(this.submitAnswersTimeout); + this.submitAnswersTimeout = null; + await this.submitNow(); + } + } +} diff --git a/packages/doenetml-worker-javascript/src/Core.js b/packages/doenetml-worker-javascript/src/Core.js index 0b3835946..68be61537 100644 --- a/packages/doenetml-worker-javascript/src/Core.js +++ b/packages/doenetml-worker-javascript/src/Core.js @@ -9,7 +9,6 @@ import { assignDoenetMLRange, findAllNewlines, flattenDeep, - data_format_version, } from "@doenet/utils"; import { convertToErrorComponent } from "./utils/dast/errors"; import { gatherVariantComponents, getNumVariants } from "./utils/variants"; @@ -28,21 +27,28 @@ import { unwrapSource, } from "./utils/dast/convertNormalizedDast"; import { DependencyHandler } from "./Dependencies"; +import { AutoSubmitManager } from "./AutoSubmitManager"; +import { DiagnosticsManager } from "./DiagnosticsManager"; +import { NavigationHandler } from "./NavigationHandler"; +import { ResolverAdapter } from "./ResolverAdapter"; +import { StatePersistence } from "./StatePersistence"; +import { VisibilityTracker } from "./VisibilityTracker"; +import { + findCaseInsensitiveMatches as resolveCaseInsensitiveMatches, + matchPublicStateVariables as resolveMatchPublicStateVariables, + substituteAliases as resolveSubstituteAliases, + publicCaseInsensitiveAliasSubstitutions as resolvePublicCaseInsensitiveAliasSubstitutions, + checkIfArrayEntry as resolveCheckIfArrayEntry, +} from "./StateVariableNameResolver"; import { returnDefaultArrayVarNameFromPropIndex, returnDefaultGetArrayKeysFromVarName, } from "./utils/stateVariables"; -import { set as idb_set } from "idb-keyval"; import { createComponentIndicesFromSerializedChildren, createNewComponentIndices, extractCreateComponentIdxMapping, } from "./utils/componentIndices"; -import { - addNodesToFlatFragment, - getEffectiveComponentIdx, -} from "./utils/resolver"; - // string to componentClass: this.componentInfoObjects.allComponentClasses["string"] // componentClass to string: componentClass.componentType @@ -112,23 +118,10 @@ export default class Core { this.cid = cid; - /** @type {({ type: "error"|"warning"|"info", message: string, position?: any, sourceDoc?: number } | { type: "accessibility", level: 1|2, message: string, position?: any, sourceDoc?: number })[]} */ - this.diagnostics = preliminaryDiagnostics - // Note: we ignore preliminary errors, as we'll gather those from the dast when processing it. - .filter((diagnostic) => diagnostic.type !== "error") - .map((diagnostic) => { - this.assertDiagnosticIsValid(diagnostic); - - return { - type: diagnostic.type, - ...(diagnostic.type === "accessibility" - ? { level: diagnostic.level } - : {}), - message: diagnostic.message, - position: diagnostic.position, - sourceDoc: diagnostic.sourceDoc, - }; - }); + this.diagnosticsManager = new DiagnosticsManager({ + core: this, + preliminaryDiagnostics, + }); this.numerics = new Numerics(); // this.flags = new Proxy(flags, readOnlyProxyHandler); //components shouldn't modify flags @@ -191,8 +184,6 @@ export default class Core { stateVariablesToEvaluate: [], }; - this.hasPendingDiagnostics = true; - this.cumulativeStateVariableChanges = JSON.parse( JSON.stringify(stateVariableChanges, serializedComponentsReplacer), serializedComponentsReviver, @@ -201,17 +192,10 @@ export default class Core { this.requestedVariantIndex = requestedVariantIndex; this.requestedVariant = requestedVariant; - this.visibilityInfo = { - componentsCurrentlyVisible: {}, - infoToSend: {}, - timeLastSent: new Date(), - saveDelay: 60000, - saveTimerId: null, - suspendDelay: 3 * 60000, - suspendTimerId: null, - suspended: false, - documentHasBeenVisible: false, - }; + this.visibilityTracker = new VisibilityTracker({ core: this }); + this.autoSubmitManager = new AutoSubmitManager({ core: this }); + this.navigationHandler = new NavigationHandler({ core: this }); + this.resolverAdapter = new ResolverAdapter({ core: this }); // console.time('serialize doenetML'); @@ -262,7 +246,7 @@ export default class Core { this.essentialValuesSavedInDefinition = {}; - this.saveStateToDBTimerId = null; + this.statePersistence = new StatePersistence({ core: this }); // rendererState the current state of each renderer, keyed by componentIdx this.rendererState = {}; @@ -475,112 +459,37 @@ export default class Core { this.updateRenderersCallback({ ...args, init, diagnostics }); } - /** - * Get pending diagnostics and reset the pending flag. - * Automatically trims the diagnostics array to prevent unbounded memory growth. - * - * @returns {Object} Object containing the current diagnostics array - * @note Diagnostics older than the 1000 most recent are discarded to manage memory - */ - getDiagnostics() { - // Keep only the last 1000 diagnostics to avoid unbounded memory growth. - // Once the limit is exceeded, older diagnostics are discarded. - // This ensures the codebase doesn't accumulate large numbers of stale messages. - const MAX_DIAGNOSTICS = 1000; - this.diagnostics = this.diagnostics.slice(-MAX_DIAGNOSTICS); + // Diagnostic state and helpers live in `this.diagnosticsManager` + // (see DiagnosticsManager.ts). The accessors and methods below preserve + // the public surface (`core.diagnostics`, `core.hasPendingDiagnostics`, + // `core.addDiagnostic`, etc.) by delegating through. - this.hasPendingDiagnostics = false; - - return { diagnostics: this.diagnostics }; + get diagnostics() { + return this.diagnosticsManager.diagnostics; } - /** - * Add a diagnostic record to `this.diagnostics`, deduplicating by - * type + message + source location. - * - * @returns {boolean} `true` if a new entry was added, `false` if deduped. - */ - assertDiagnosticIsValid({ type, level }) { - if (!["error", "warning", "info", "accessibility"].includes(type)) { - throw Error("Invalid diagnostic type"); - } - - if (type === "accessibility") { - if (level === undefined) { - throw Error("Missing accessibility diagnostic level"); - } - - if (![1, 2].includes(level)) { - throw Error("Invalid accessibility diagnostic level"); - } - } + get hasPendingDiagnostics() { + return this.diagnosticsManager.hasPendingDiagnostics; } - addDiagnostic({ type, level, message, position, sourceDoc }) { - const sameLocation = (pointA, pointB) => - (pointA?.offset ?? undefined) === (pointB?.offset ?? undefined) && - (pointA?.line ?? undefined) === (pointB?.line ?? undefined) && - (pointA?.column ?? undefined) === (pointB?.column ?? undefined); - - const haveSamePosition = (warningPosition, newPosition) => { - if (warningPosition === undefined || newPosition === undefined) { - return warningPosition === newPosition; - } - - return ( - sameLocation(warningPosition.start, newPosition.start) && - sameLocation(warningPosition.end, newPosition.end) - ); - }; - - this.assertDiagnosticIsValid({ type, level }); - - const alreadyHaveDiagnostic = this.diagnostics.some( - (diagnostic) => - diagnostic.type === type && - (type === "accessibility" - ? diagnostic.level === level - : true) && - diagnostic.message === message && - diagnostic.sourceDoc === sourceDoc && - haveSamePosition(diagnostic.position, position), - ); + set hasPendingDiagnostics(value) { + this.diagnosticsManager.hasPendingDiagnostics = value; + } - if (alreadyHaveDiagnostic) { - return false; - } + getDiagnostics() { + return this.diagnosticsManager.getDiagnostics(); + } - this.diagnostics.push({ - type, - ...(type === "accessibility" ? { level } : {}), - message, - position, - sourceDoc, - }); + assertDiagnosticIsValid(diagnostic) { + this.diagnosticsManager.assertDiagnosticIsValid(diagnostic); + } - this.hasPendingDiagnostics = true; - return true; + addDiagnostic(diagnostic) { + return this.diagnosticsManager.addDiagnostic(diagnostic); } - /** - * Find the nearest available source position/sourceDoc for a component, - * walking up ancestors when the component itself has no position. - */ getSourceLocationForComponent(component) { - let position = component.position; - let sourceDoc = component.sourceDoc; - let comp = component; - - while (position === undefined) { - if (!(comp.parentIdx > 0)) { - break; - } - comp = this._components[comp.parentIdx]; - position = comp.position; - sourceDoc = comp.sourceDoc; - } - - return { position, sourceDoc }; + return this.diagnosticsManager.getSourceLocationForComponent(component); } async addComponents({ @@ -2726,384 +2635,31 @@ export default class Core { return { success: true, compositesExpanded: [component.componentIdx] }; } - async addReplacementsToResolver({ - serializedReplacements, - component, - updateOldReplacementsStart, - updateOldReplacementsEnd, - blankStringReplacements, - }) { - if (component.constructor.replacementsAlreadyInResolver) { - return; - } + // Resolver adapter methods live in `this.resolverAdapter` + // (see ResolverAdapter.ts). The methods below preserve the public surface + // (`core.addReplacementsToResolver`, etc.) by delegating through. - const { parentIdx, indexResolution } = - await this.determineParentAndIndexResolutionForResolver({ - component, - updateOldReplacementsStart, - updateOldReplacementsEnd, - blankStringReplacements, - }); - - // If `createComponentIdx` was specified, the one replacement is already in the resolver, - // so we just add its children and attribute components/references. - // Otherwise add all replacements. - const fragmentChildren = []; - let parentSourceSequence = null; - if (component.attributes.createComponentIdx != null) { - if (serializedReplacements[0]?.children) { - fragmentChildren.push(...serializedReplacements[0].children); - } - for (const attrName in serializedReplacements[0]?.attributes) { - const attribute = - serializedReplacements[0].attributes[attrName]; - if (attribute.type === "component") { - fragmentChildren.push(attribute.component); - } else if (attribute.type === "references") { - fragmentChildren.push(...attribute.references); - } - } - - // if the replacement that is the fragment parent has a source sequence, - // then add that as the `parentSourceSequence` of the flat fragment - let sourceSequence = - serializedReplacements[0]?.attributes["source:sequence"]; - if (sourceSequence) { - parentSourceSequence = { - type: "attribute", - name: "source:sequence", - parent: component.attributes.createComponentIdx.primitive - .number, - children: sourceSequence.children.filter( - (child) => typeof child === "string", - ), - sourceDoc: sourceSequence.sourceDoc, - }; - } - } else { - fragmentChildren.push(...serializedReplacements); - } - - // We add all the parent's descendants to the resolver - const flatFragment = { - children: fragmentChildren.map((child) => - typeof child === "string" - ? child - : getEffectiveComponentIdx(child), - ), - nodes: [], - parentIdx, - parentSourceSequence, - idxMap: {}, - }; - - addNodesToFlatFragment({ - flatFragment, - serializedComponents: fragmentChildren, - parentIdx, - }); - - if ( - (flatFragment.nodes.length > 0 || indexResolution !== "None") && - this.addNodesToResolver - ) { - // console.log("add nodes to resolver", { - // flatFragment, - // indexResolution, - // }); - this.addNodesToResolver(flatFragment, indexResolution); - - this.rootNames = this.calculateRootNames?.().names; - - let indexParent = - indexResolution.ReplaceAll?.parent ?? - indexResolution.ReplaceRange?.parent ?? - null; - - if ( - indexParent !== null && - indexParent !== component.componentIdx - ) { - const indexParentComposite = this._components[indexParent]; - - if (indexParentComposite) { - await this.dependencies.addBlockersFromChangedReplacements( - indexParentComposite, - ); - } - } - } + async addReplacementsToResolver(args) { + return this.resolverAdapter.addReplacementsToResolver(args); } - async determineParentAndIndexResolutionForResolver({ - component, - updateOldReplacementsStart, - updateOldReplacementsEnd, - blankStringReplacements, - }) { - // If the composite was created as a child for a list, - // then the parent for resolving names is that list (the parent of the resolver). - // If `createComponentIdx` was specified, then that should be the parent for resolving names. - // Else, the composite should be the parent for resolving names. - - let update_start = updateOldReplacementsStart; - let update_end = updateOldReplacementsEnd; - - if ( - updateOldReplacementsStart !== undefined && - updateOldReplacementsEnd !== undefined - ) { - // We are replacing a range of replacement, but these include blank strings. - // Adjust the range to ignore blank strings - for (const [ - i, - isBlankString, - ] of blankStringReplacements.entries()) { - if (i >= updateOldReplacementsEnd) { - break; - } - if (isBlankString) { - update_end--; - if (i < updateOldReplacementsStart) { - update_start--; - } - } - } - } - - let parentIdx; - - let indexResolution = "None"; - - if (component.doenetAttributes.forList) { - // Don't add index resolutions in this case, - // we're just adding to the children of the list, not the replacements of the list - parentIdx = component.parentIdx; - } else if (component.attributes.createComponentIdx?.primitive) { - // If `createComponentIdx` is set, then we have a copy component created from an `extend` attribute. - // That component is already in the resolver so will be the parent of the fragment added to the browser. - parentIdx = - component.attributes.createComponentIdx?.primitive.value; - - // If the component type of that parent, specified by `createComponentOfType`, is a composite, - // then it could have an index specified, so we add an index resolution - if ( - component.attributes.createComponentOfType?.primitive && - this.componentInfoObjects.isCompositeComponent({ - componentType: - component.attributes.createComponentOfType.primitive - .value, - includeNonStandard: true, - }) - ) { - indexResolution = { ReplaceAll: { parent: parentIdx } }; - - if (update_start !== undefined && update_end !== undefined) { - const parent = this._components[parentIdx]; - - indexResolution = { - ReplaceRange: { - parent: parentIdx, - range: { start: update_start, end: update_end }, - }, - }; - } - } - } else if (component.componentType === "_copy") { - // If we have a copy that wasn't from an extend, then it was from a reference. - // Although references don't have names that can be - // Copy components are typically not part of the resolver structure and generally skipped. - // Since we don't allow direct authoring of copy components, - // they should occur only from references - - // determine if is a replacement of another type of composite - let copyComponent = component; - parentIdx = component.componentIdx; - - while (copyComponent.replacementOf) { - if (copyComponent.replacementOf.componentType === "_copy") { - copyComponent = copyComponent.replacementOf; - continue; - } else { - break; - } - } - - // now we have a copyComponent that is not a replacement of a copy - if (copyComponent.replacementOf) { - const indexParent = copyComponent.replacementOf; - - // determine where the replacement will end up being spliced in - - let start_idx, end_idx; - - async function calcStartEndIdx(replacements) { - let nonWithheldReplacements = []; - for (const repl of replacements) { - if ( - typeof repl === "string" || - !(await repl.stateValues - .isInactiveCompositeReplacement) - ) { - nonWithheldReplacements.push(repl); - } - } - - const nonBlankStringReplacements = - nonWithheldReplacements.filter( - (x) => typeof x !== "string" || x.trim() !== "", - ); - const replacementsWithoutExpandedCopies = []; - - let i = 0; - - for (const repl of nonBlankStringReplacements) { - if (repl.componentType == "_copy") { - if (!repl.isExpanded) { - if ( - repl.componentIdx === - copyComponent.componentIdx - ) { - start_idx = i; - end_idx = i + 1; - } - replacementsWithoutExpandedCopies.push(repl); - i++; - } else { - let replReplacements = repl.replacements; - if (repl.replacementsToWithhold) { - replReplacements = replReplacements.slice( - 0, - replReplacements.length - - repl.replacementsToWithhold, - ); - } - - const newReplacements = - await calcStartEndIdx(replReplacements); - const n = newReplacements.length; - - if ( - repl.componentIdx === - copyComponent.componentIdx - ) { - if ( - update_start !== undefined && - update_end !== undefined - ) { - start_idx = i + update_start; - end_idx = i + update_end; - } else { - start_idx = i; - end_idx = i + n; - } - } - - replacementsWithoutExpandedCopies.push( - ...newReplacements, - ); - i += n; - } - } else { - replacementsWithoutExpandedCopies.push(repl); - i++; - } - } - - return replacementsWithoutExpandedCopies; - } - - await calcStartEndIdx(indexParent.replacements); - - if (start_idx !== undefined && end_idx !== undefined) { - indexResolution = { - ReplaceRange: { - parent: indexParent.componentIdx, - range: { start: start_idx, end: end_idx }, - }, - }; - } else { - // if the copy was not found as a replacement of the composite, - // then it wasn't a top-level replacement and it doesn't affect the composite's index resolution - indexResolution = "None"; - } - } else { - parentIdx = copyComponent.componentIdx; - indexResolution = { ReplaceAll: { parent: parentIdx } }; - } - } else { - parentIdx = component.componentIdx; - - if ( - this.componentInfoObjects.isCompositeComponent({ - componentType: component.componentType, - includeNonStandard: true, - }) - ) { - if (update_start !== undefined && update_end !== undefined) { - indexResolution = { - ReplaceRange: { - parent: parentIdx, - range: { start: update_start, end: update_end }, - }, - }; - } else { - indexResolution = { ReplaceAll: { parent: parentIdx } }; - } - } - } - - return { parentIdx, indexResolution }; + async determineParentAndIndexResolutionForResolver(args) { + return this.resolverAdapter.determineParentAndIndexResolutionForResolver( + args, + ); } addComponentsToResolver(components, parentIdx) { - const flatFragment = { - children: components.map((child) => - typeof child === "string" - ? child - : getEffectiveComponentIdx(child), - ), - nodes: [], - parentIdx, - idxMap: {}, - }; - - addNodesToFlatFragment({ - flatFragment, - serializedComponents: components, + return this.resolverAdapter.addComponentsToResolver( + components, parentIdx, - }); - - // console.log("add nodes from components to resolver", { - // flatFragment, - // }); - - if (this.addNodesToResolver) { - this.addNodesToResolver(flatFragment, "None"); - - this.rootNames = this.calculateRootNames?.().names; - } + ); } - gatherDiagnosticsAndAssignDoenetMLRange({ - components, - diagnostics, - position, - sourceDoc, - overwriteDoenetMLRange = false, - }) { - assignDoenetMLRange( - components, - position, - sourceDoc, - overwriteDoenetMLRange, + gatherDiagnosticsAndAssignDoenetMLRange(args) { + return this.resolverAdapter.gatherDiagnosticsAndAssignDoenetMLRange( + args, ); - assignDoenetMLRange(diagnostics, position, sourceDoc); - - // Add all diagnostics, preserving their existing type field - for (const diagnostic of diagnostics) { - this.addDiagnostic(diagnostic); - } } async expandShadowingComposite(component) { @@ -8186,241 +7742,49 @@ export default class Core { } } - findCaseInsensitiveMatches({ stateVariables, componentClass }) { - let stateVarInfo = - this.componentInfoObjects.stateVariableInfo[ - componentClass.componentType - ]; - - let newVariables = []; + // The five state-variable name-resolution helpers below live as pure + // functions in StateVariableNameResolver.ts. The wrappers preserve the + // public surface (`core.findCaseInsensitiveMatches`, etc., plus the + // by-reference passes used in composite sugar functions) by injecting + // `componentInfoObjects` and delegating. - for (let stateVariable of stateVariables) { - let foundMatch = false; - - let lowerCaseVarName = stateVariable.toLowerCase(); - - for (let varName in stateVarInfo.stateVariableDescriptions) { - if (lowerCaseVarName === varName.toLowerCase()) { - foundMatch = true; - newVariables.push(varName); - break; - } - } - - if (foundMatch) { - continue; - } - - let isArraySize = false; - let lowerCaseNameMinusSize = lowerCaseVarName; - if (lowerCaseVarName.substring(0, 13) === "__array_size_") { - isArraySize = true; - lowerCaseNameMinusSize = lowerCaseVarName.substring(13); - } - - for (let aliasName in stateVarInfo.aliases) { - if (lowerCaseNameMinusSize === aliasName.toLowerCase()) { - // don't substitute alias here, just fix case - if (isArraySize) { - aliasName = "__array_size_" + aliasName; - } - newVariables.push(aliasName); - foundMatch = true; - break; - } - } - if (foundMatch) { - continue; - } - - let arrayEntryPrefixesLongestToShortest = Object.keys( - stateVarInfo.arrayEntryPrefixes, - ).sort((a, b) => b.length - a.length); - for (let prefix of arrayEntryPrefixesLongestToShortest) { - if ( - lowerCaseVarName.substring(0, prefix.length) === - prefix.toLowerCase() - ) { - // TODO: the varEnding is still a case-senstitive match - // Should we require that getArrayKeysFromVarName have - // a case-insensitive mode? - let arrayVariableName = - stateVarInfo.arrayEntryPrefixes[prefix] - .arrayVariableName; - let arrayStateVarDescription = - stateVarInfo.stateVariableDescriptions[ - arrayVariableName - ]; - let arrayKeys = - arrayStateVarDescription.getArrayKeysFromVarName({ - arrayEntryPrefix: prefix, - varEnding: stateVariable.substring(prefix.length), - numDimensions: - arrayStateVarDescription.numDimensions, - }); - if (arrayKeys.length > 0) { - let newVarName = - prefix + lowerCaseVarName.substring(prefix.length); - foundMatch = true; - newVariables.push(newVarName); - break; - } - } - } - - if (foundMatch) { - continue; - } - - // no match, so don't alter - newVariables.push(stateVariable); - } - - return newVariables; + findCaseInsensitiveMatches({ stateVariables, componentClass }) { + return resolveCaseInsensitiveMatches({ + stateVariables, + componentClass, + componentInfoObjects: this.componentInfoObjects, + }); } matchPublicStateVariables({ stateVariables, componentClass }) { - let stateVarInfo = - this.componentInfoObjects.publicStateVariableInfo[ - componentClass.componentType - ]; - - let newVariables = []; - - for (let stateVariable of stateVariables) { - if (stateVariable in stateVarInfo.stateVariableDescriptions) { - // found public - newVariables.push(stateVariable); - continue; - } - - let varName = stateVariable; - - if (varName in stateVarInfo.aliases) { - varName = stateVarInfo.aliases[varName]; - - // check again to see if alias is public - if (varName in stateVarInfo.stateVariableDescriptions) { - // found public - newVariables.push(varName); - continue; - } - } - - let foundMatch = false; - - let arrayEntryPrefixesLongestToShortest = Object.keys( - stateVarInfo.arrayEntryPrefixes, - ).sort((a, b) => b.length - a.length); - for (let prefix of arrayEntryPrefixesLongestToShortest) { - if (varName.substring(0, prefix.length) === prefix) { - let arrayVariableName = - stateVarInfo.arrayEntryPrefixes[prefix] - .arrayVariableName; - let arrayStateVarDescription = - stateVarInfo.stateVariableDescriptions[ - arrayVariableName - ]; - let arrayKeys = - arrayStateVarDescription.getArrayKeysFromVarName({ - arrayEntryPrefix: prefix, - varEnding: varName.substring(prefix.length), - numDimensions: - arrayStateVarDescription.numDimensions, - }); - if (arrayKeys.length > 0) { - foundMatch = true; - break; - } - } - } - - if (foundMatch) { - newVariables.push(stateVariable); - } else { - // no match, so make it a name that won't match - newVariables.push("__not_public_" + stateVariable); - } - } - - return newVariables; + return resolveMatchPublicStateVariables({ + stateVariables, + componentClass, + componentInfoObjects: this.componentInfoObjects, + }); } substituteAliases({ stateVariables, componentClass }) { - let newVariables = []; - - let stateVarInfo = - this.componentInfoObjects.stateVariableInfo[ - componentClass.componentType - ]; - - for (let stateVariable of stateVariables) { - let isArraySize = false; - if (stateVariable.substring(0, 13) === "__array_size_") { - isArraySize = true; - stateVariable = stateVariable.substring(13); - } - stateVariable = - stateVariable in stateVarInfo.aliases - ? stateVarInfo.aliases[stateVariable] - : stateVariable; - if (isArraySize) { - stateVariable = "__array_size_" + stateVariable; - } - newVariables.push(stateVariable); - } - - return newVariables; + return resolveSubstituteAliases({ + stateVariables, + componentClass, + componentInfoObjects: this.componentInfoObjects, + }); } publicCaseInsensitiveAliasSubstitutions({ stateVariables, componentClass, }) { - let mappedVarNames = this.findCaseInsensitiveMatches({ + return resolvePublicCaseInsensitiveAliasSubstitutions({ stateVariables, componentClass, + componentInfoObjects: this.componentInfoObjects, }); - - mappedVarNames = this.matchPublicStateVariables({ - stateVariables: mappedVarNames, - componentClass, - }); - - mappedVarNames = this.substituteAliases({ - stateVariables: mappedVarNames, - componentClass, - }); - - return mappedVarNames; } checkIfArrayEntry({ stateVariable, component }) { - // check if stateVariable begins when an arrayEntry - for (let arrayEntryPrefix in component.arrayEntryPrefixes) { - if ( - stateVariable.substring(0, arrayEntryPrefix.length) === - arrayEntryPrefix - ) { - let arrayVariableName = - component.arrayEntryPrefixes[arrayEntryPrefix]; - let arrayStateVarObj = component.state[arrayVariableName]; - let arrayKeys = arrayStateVarObj.getArrayKeysFromVarName({ - arrayEntryPrefix, - varEnding: stateVariable.substring(arrayEntryPrefix.length), - numDimensions: arrayStateVarObj.numDimensions, - }); - if (arrayKeys.length > 0) { - return { - isArrayEntry: true, - arrayVariableName, - arrayEntryPrefix, - }; - } - } - } - - return { isArrayEntry: false }; + return resolveCheckIfArrayEntry({ stateVariable, component }); } async createFromArrayEntry({ @@ -9955,47 +9319,9 @@ export default class Core { } removeComponentsFromResolver(componentsToRemove) { - if (componentsToRemove.length === 0) { - return; - } - - const flatElements = componentsToRemove.map((comp) => { - let flatElement = { - type: "element", - name: comp.componentType, - parent: comp.parentIdx, - children: [], - attributes: [], - idx: comp.componentIdx, - }; - - if (comp.attributes.createComponentName && !comp.isExpanded) { - flatElement.attributes.push({ - type: "attribute", - name: "name", - parent: comp.parentIdx, - children: [ - comp.attributes.createComponentName.primitive.value, - ], - }); - } else if (comp.attributes.name) { - flatElement.attributes.push({ - type: "attribute", - name: "name", - parent: comp.parentIdx, - children: [comp.attributes.name.primitive.value], - }); - } - return flatElement; - }); - - if (this.deleteNodesFromResolver) { - this.deleteNodesFromResolver({ - nodes: flatElements, - }); - - this.rootNames = this.calculateRootNames?.().names; - } + return this.resolverAdapter.removeComponentsFromResolver( + componentsToRemove, + ); } determineComponentsToDelete({ @@ -11847,12 +11173,8 @@ export default class Core { alreadySaved = true; } if (!alreadySaved && !doNotSave) { - clearTimeout(this.saveDocStateTimeoutID); - //Debounce the save to localstorage and then to DB with a throttle - this.saveDocStateTimeoutID = setTimeout(() => { - this.saveState(); - }, 1000); + this.statePersistence.scheduleSave(1000); } // evaluate componentCreditAchieved so that will be fresh @@ -11954,140 +11276,29 @@ export default class Core { this.sendEvent(payload); } - processVisibilityChangedEvent(event) { - let componentIdx = event.object.componentIdx; - let isVisible = event.result.isVisible; - - if (isVisible) { - if (!this.visibilityInfo.componentsCurrentlyVisible[componentIdx]) { - this.visibilityInfo.componentsCurrentlyVisible[componentIdx] = - new Date(); - } - if (componentIdx === this.documentIdx) { - if (!this.visibilityInfo.documentHasBeenVisible) { - this.visibilityInfo.documentHasBeenVisible = true; - this.onDocumentFirstVisible(); - } - } - } else { - let begin = - this.visibilityInfo.componentsCurrentlyVisible[componentIdx]; - if (begin) { - delete this.visibilityInfo.componentsCurrentlyVisible[ - componentIdx - ]; + // Visibility tracking lives in `this.visibilityTracker` + // (see VisibilityTracker.ts). The accessor and methods below preserve + // the public surface (`core.visibilityInfo`, `core.processVisibilityChangedEvent`, + // etc.) by delegating through. - let timeInSeconds = - (new Date() - - Math.max(begin, this.visibilityInfo.timeLastSent)) / - 1000; + get visibilityInfo() { + return this.visibilityTracker.info; + } - if (this.visibilityInfo.infoToSend[componentIdx]) { - this.visibilityInfo.infoToSend[componentIdx] += - timeInSeconds; - } else { - this.visibilityInfo.infoToSend[componentIdx] = - timeInSeconds; - } - } - } + processVisibilityChangedEvent(event) { + return this.visibilityTracker.processVisibilityChangedEvent(event); } sendVisibilityChangedEvents() { - let infoToSend = { ...this.visibilityInfo.infoToSend }; - this.visibilityInfo.infoToSend = {}; - let timeLastSent = this.visibilityInfo.timeLastSent; - this.visibilityInfo.timeLastSent = new Date(); - let currentVisible = { - ...this.visibilityInfo.componentsCurrentlyVisible, - }; - - for (const componentIdxStr in currentVisible) { - let timeInSeconds = - (this.visibilityInfo.timeLastSent - - Math.max(timeLastSent, currentVisible[componentIdxStr])) / - 1000; - if (infoToSend[componentIdxStr]) { - infoToSend[componentIdxStr] += timeInSeconds; - } else { - infoToSend[componentIdxStr] = timeInSeconds; - } - } - - for (const componentIdxStr in infoToSend) { - infoToSend[componentIdxStr] = Math.round( - infoToSend[componentIdxStr], - ); - if (!infoToSend[componentIdxStr]) { - // delete if rounded down to zero - delete infoToSend[componentIdxStr]; - } - } - - let promise; - - if (Object.keys(infoToSend).length > 0) { - let event = { - object: { - componentIdx: this.documentIdx, - componentType: "document", - }, - verb: "isVisible", - result: infoToSend, - }; - - promise = new Promise((resolve, reject) => { - this.processQueue.push({ - type: "recordEvent", - event, - resolve, - reject, - }); - - if (!this.processing) { - this.processing = true; - this.executeProcesses(); - } - }); - } - - if (!this.visibilityInfo.suspended) { - clearTimeout(this.visibilityInfo.saveTimerId); - this.visibilityInfo.saveTimerId = setTimeout( - this.sendVisibilityChangedEvents.bind(this), - this.visibilityInfo.saveDelay, - ); - } - - return promise; + return this.visibilityTracker.sendVisibilityChangedEvents(); } async suspendVisibilityMeasuring() { - clearTimeout(this.visibilityInfo.saveTimerId); - clearTimeout(this.visibilityInfo.suspendTimerId); - if (!this.visibilityInfo.suspended) { - this.visibilityInfo.suspended = true; - await this.sendVisibilityChangedEvents(); - } + return this.visibilityTracker.suspendVisibilityMeasuring(); } resumeVisibilityMeasuring() { - if (this.visibilityInfo.suspended) { - // restart visibility measuring - this.visibilityInfo.suspended = false; - this.visibilityInfo.timeLastSent = new Date(); - clearTimeout(this.visibilityInfo.saveTimerId); - this.visibilityInfo.saveTimerId = setTimeout( - this.sendVisibilityChangedEvents.bind(this), - this.visibilityInfo.saveDelay, - ); - } - - clearTimeout(this.visibilityInfo.suspendTimerId); - this.visibilityInfo.suspendTimerId = setTimeout( - this.suspendVisibilityMeasuring.bind(this), - this.visibilityInfo.suspendDelay, - ); + return this.visibilityTracker.resumeVisibilityMeasuring(); } async executeUpdateStateVariables(newStateVariableValues) { @@ -13415,104 +12626,21 @@ export default class Core { } } + // State persistence (save to localStorage / database) lives in + // `this.statePersistence` (see StatePersistence.ts). The methods below + // preserve the public surface (`core.saveImmediately`, `core.saveState`, + // `core.saveChangesToDatabase`) by delegating through. + async saveImmediately() { - if (this.saveDocStateTimeoutID) { - // if in debounce to save doc to local storage - // then immediate save to local storage - // and override timeout to save to database - clearTimeout(this.saveDocStateTimeoutID); - await this.saveState(true); - } else { - // else override timeout to save any pending changes to database - await this.saveChangesToDatabase(true); - } + return this.statePersistence.saveImmediately(); } async saveState(overrideThrottle = false, onSubmission = false) { - this.saveDocStateTimeoutID = null; - - if (!this.flags.allowSaveState && !this.flags.allowLocalState) { - return; - } - - let coreStateString = JSON.stringify( - this.cumulativeStateVariableChanges, - serializedComponentsReplacer, - ); - let rendererStateString = null; - - if (this.flags.saveRendererState) { - rendererStateString = JSON.stringify( - this.rendererState, - serializedComponentsReplacer, - ); - } - - if (this.flags.allowLocalState) { - await idb_set( - `${this.activityId}|${this.docId}|${this.attemptNumber}|${this.cid}`, - { - data_format_version, - coreState: coreStateString, - rendererState: rendererStateString, - coreInfo: this.coreInfoString, - }, - ); - } - - if (!this.flags.allowSaveState) { - return; - } - - this.docStateToBeSavedToDatabase = { - cid: this.cid, - coreInfo: this.coreInfoString, - coreState: coreStateString, - rendererState: rendererStateString, - initializeCounters: this.initializeCounters, - docId: this.docId, - attemptNumber: this.attemptNumber, - activityId: this.activityId, - onSubmission, - }; - - // mark presence of changes - // so that next call to saveChangesToDatabase will save changes - this.changesToBeSaved = true; - - // if not currently in throttle, save changes to database - await this.saveChangesToDatabase(overrideThrottle); + return this.statePersistence.saveState(overrideThrottle, onSubmission); } async saveChangesToDatabase(overrideThrottle) { - // throttle save to database at 60 seconds - - if (!this.changesToBeSaved) { - return; - } - - if (this.saveStateToDBTimerId !== null) { - if (overrideThrottle) { - clearTimeout(this.saveStateToDBTimerId); - } else { - return; - } - } - - this.changesToBeSaved = false; - - // check for changes again after 60 seconds - this.saveStateToDBTimerId = setTimeout(() => { - this.saveStateToDBTimerId = null; - this.saveChangesToDatabase(); - }, 60000); - - this.reportScoreAndStateCallback({ - state: { ...this.docStateToBeSavedToDatabase }, - score: await this.document.stateValues.creditAchieved, - }); - - return; + return this.statePersistence.saveChangesToDatabase(overrideThrottle); } /** @@ -13546,40 +12674,11 @@ export default class Core { } } - async handleNavigatingToComponent({ componentIdx, hash }) { - let component = this._components[componentIdx]; - if (component) { - let componentAndAncestors = [ - componentIdx, - ...component.ancestors.map((x) => x.componentIdx), - ]; - let openedParent = false; - for (let cIdx of componentAndAncestors) { - let comp = this._components[cIdx]; - if (comp.actions?.revealSection) { - let isOpen = await comp.stateValues.open; + // Navigation methods delegate to `this.navigationHandler` + // (see NavigationHandler.ts). - if (isOpen === false) { - await this.performAction({ - componentIdx: cIdx, - actionName: "revealSection", - }); - if (cIdx !== componentIdx) { - openedParent = true; - } - } - } - } - if (openedParent) { - // If just opened parent, then we couldn't have navigated to target yet - // as the target didn't exist in the DOM when the parent was closed. - // Navigate to the specified hash now. - postMessage({ - messageType: "navigateToHash", - args: { hash }, - }); - } - } + async handleNavigatingToComponent(args) { + return this.navigationHandler.handleNavigatingToComponent(args); } async terminate() { @@ -13592,10 +12691,7 @@ export default class Core { // suspend visibility measuring so that remaining times collected are saved await this.suspendVisibilityMeasuring(); - if (this.submitAnswersTimeout) { - clearTimeout(this.submitAnswersTimeout); - await this.autoSubmitAnswers(); - } + await this.autoSubmitManager.flush(); this.stopProcessingRequests = true; @@ -13611,36 +12707,16 @@ export default class Core { await this.saveImmediately(); } - recordAnswerToAutoSubmit(componentIdx) { - if (!this.answersToSubmit) { - this.answersToSubmit = []; - } - - if (!this.answersToSubmit.includes(componentIdx)) { - this.answersToSubmit.push(componentIdx); - } + // Auto-submit-answer queue lives in `this.autoSubmitManager` + // (see AutoSubmitManager.ts). The methods below preserve the public surface + // (`core.recordAnswerToAutoSubmit`, `core.autoSubmitAnswers`) by delegating. - clearTimeout(this.submitAnswersTimeout); - - //Debounce the submit answers - this.submitAnswersTimeout = setTimeout(() => { - this.autoSubmitAnswers(); - }, 1000); + recordAnswerToAutoSubmit(componentIdx) { + this.autoSubmitManager.recordAnswer(componentIdx); } async autoSubmitAnswers() { - let toSubmit = this.answersToSubmit; - this.answersToSubmit = []; - for (let componentIdx of toSubmit) { - let component = this._components[componentIdx]; - - if (component.actions.submitAnswer) { - await this.requestAction({ - componentIdx, - actionName: "submitAnswer", - }); - } - } + return this.autoSubmitManager.submitNow(); } requestComponentDoenetML(componentIdx, displayOnlyChildren) { @@ -13719,11 +12795,7 @@ export default class Core { } navigateToTarget(args) { - postMessage({ - messageType: "navigateToTarget", - coreId: this.coreId, - args, - }); + return this.navigationHandler.navigateToTarget(args); } } diff --git a/packages/doenetml-worker-javascript/src/DiagnosticsManager.ts b/packages/doenetml-worker-javascript/src/DiagnosticsManager.ts new file mode 100644 index 000000000..81d4787a7 --- /dev/null +++ b/packages/doenetml-worker-javascript/src/DiagnosticsManager.ts @@ -0,0 +1,182 @@ +type DiagnosticType = "error" | "warning" | "info" | "accessibility"; +type DiagnosticLevel = 1 | 2; + +type DiagnosticInput = { + type: DiagnosticType; + level?: DiagnosticLevel; + message: string; + position?: any; + sourceDoc?: number; +}; + +type DiagnosticRecord = { + type: DiagnosticType; + level?: DiagnosticLevel; + message: string; + position?: any; + sourceDoc?: number; +}; + +type AssertableDiagnostic = { + type: DiagnosticType; + level?: DiagnosticLevel; +}; + +/** + * Owns the diagnostics queue (errors, warnings, info, accessibility) for a Core + * instance. Core delegates to this manager for adding and reading diagnostics. + * + * Holds a back-reference to Core so `getSourceLocationForComponent` can walk + * the parent chain via `core._components`. + */ +export class DiagnosticsManager { + core: any; + diagnostics: DiagnosticRecord[]; + hasPendingDiagnostics: boolean; + + constructor({ + core, + preliminaryDiagnostics, + }: { + core: any; + preliminaryDiagnostics: DiagnosticInput[]; + }) { + this.core = core; + + this.diagnostics = preliminaryDiagnostics + // Note: we ignore preliminary errors, as we'll gather those from the dast when processing it. + .filter((diagnostic) => diagnostic.type !== "error") + .map((diagnostic) => { + this.assertDiagnosticIsValid(diagnostic); + + return { + type: diagnostic.type, + ...(diagnostic.type === "accessibility" + ? { level: diagnostic.level } + : {}), + message: diagnostic.message, + position: diagnostic.position, + sourceDoc: diagnostic.sourceDoc, + }; + }); + + this.hasPendingDiagnostics = true; + } + + /** + * Get pending diagnostics and reset the pending flag. + * Automatically trims the diagnostics array to prevent unbounded memory growth. + * + * @returns Object containing the current diagnostics array + * @note Diagnostics older than the 1000 most recent are discarded to manage memory + */ + getDiagnostics(): { diagnostics: DiagnosticRecord[] } { + // Keep only the last 1000 diagnostics to avoid unbounded memory growth. + // Once the limit is exceeded, older diagnostics are discarded. + // This ensures the codebase doesn't accumulate large numbers of stale messages. + const MAX_DIAGNOSTICS = 1000; + this.diagnostics = this.diagnostics.slice(-MAX_DIAGNOSTICS); + + this.hasPendingDiagnostics = false; + + return { diagnostics: this.diagnostics }; + } + + assertDiagnosticIsValid({ type, level }: AssertableDiagnostic): void { + if (!["error", "warning", "info", "accessibility"].includes(type)) { + throw Error("Invalid diagnostic type"); + } + + if (type === "accessibility") { + if (level === undefined) { + throw Error("Missing accessibility diagnostic level"); + } + + if (![1, 2].includes(level)) { + throw Error("Invalid accessibility diagnostic level"); + } + } + } + + /** + * Add a diagnostic record to `this.diagnostics`, deduplicating by + * type + message + source location. + * + * @returns `true` if a new entry was added, `false` if deduped. + */ + addDiagnostic({ + type, + level, + message, + position, + sourceDoc, + }: DiagnosticInput): boolean { + const sameLocation = (pointA: any, pointB: any) => + (pointA?.offset ?? undefined) === (pointB?.offset ?? undefined) && + (pointA?.line ?? undefined) === (pointB?.line ?? undefined) && + (pointA?.column ?? undefined) === (pointB?.column ?? undefined); + + const haveSamePosition = (warningPosition: any, newPosition: any) => { + if (warningPosition === undefined || newPosition === undefined) { + return warningPosition === newPosition; + } + + return ( + sameLocation(warningPosition.start, newPosition.start) && + sameLocation(warningPosition.end, newPosition.end) + ); + }; + + this.assertDiagnosticIsValid({ type, level }); + + const alreadyHaveDiagnostic = this.diagnostics.some( + (diagnostic) => + diagnostic.type === type && + (type === "accessibility" + ? diagnostic.level === level + : true) && + diagnostic.message === message && + diagnostic.sourceDoc === sourceDoc && + haveSamePosition(diagnostic.position, position), + ); + + if (alreadyHaveDiagnostic) { + return false; + } + + this.diagnostics.push({ + type, + ...(type === "accessibility" ? { level } : {}), + message, + position, + sourceDoc, + }); + + this.hasPendingDiagnostics = true; + return true; + } + + /** + * Find the nearest available source position/sourceDoc for a component, + * walking up ancestors when the component itself has no position. + */ + getSourceLocationForComponent(component: any): { + position: any; + sourceDoc: number | undefined; + } { + let position = component.position; + let sourceDoc = component.sourceDoc; + let comp = component; + + while (position === undefined) { + if (!(comp.parentIdx > 0)) { + break; + } + comp = this.core._components[comp.parentIdx]; + position = comp.position; + sourceDoc = comp.sourceDoc; + } + + return { position, sourceDoc }; + } +} diff --git a/packages/doenetml-worker-javascript/src/NavigationHandler.ts b/packages/doenetml-worker-javascript/src/NavigationHandler.ts new file mode 100644 index 000000000..1befbee1c --- /dev/null +++ b/packages/doenetml-worker-javascript/src/NavigationHandler.ts @@ -0,0 +1,66 @@ +/** + * Handles navigation actions: revealing closed sections in the ancestor chain + * before navigating to a target component, and posting `navigateToTarget` + * messages back to the host. + * + * Stateless — holds a back-reference to Core to read `_components`, + * dispatch `performAction`, and read `coreId`. + */ +export class NavigationHandler { + core: any; + + constructor({ core }: { core: any }) { + this.core = core; + } + + async handleNavigatingToComponent({ + componentIdx, + hash, + }: { + componentIdx: number; + hash: string; + }): Promise { + let component = this.core._components[componentIdx]; + if (!component) { + return; + } + let componentAndAncestors = [ + componentIdx, + ...component.ancestors.map((x: any) => x.componentIdx), + ]; + let openedParent = false; + for (let cIdx of componentAndAncestors) { + let comp = this.core._components[cIdx]; + if (comp.actions?.revealSection) { + let isOpen = await comp.stateValues.open; + + if (isOpen === false) { + await this.core.performAction({ + componentIdx: cIdx, + actionName: "revealSection", + }); + if (cIdx !== componentIdx) { + openedParent = true; + } + } + } + } + if (openedParent) { + // If just opened parent, then we couldn't have navigated to target yet + // as the target didn't exist in the DOM when the parent was closed. + // Navigate to the specified hash now. + postMessage({ + messageType: "navigateToHash", + args: { hash }, + }); + } + } + + navigateToTarget(args: any): void { + postMessage({ + messageType: "navigateToTarget", + coreId: this.core.coreId, + args, + }); + } +} diff --git a/packages/doenetml-worker-javascript/src/ResolverAdapter.ts b/packages/doenetml-worker-javascript/src/ResolverAdapter.ts new file mode 100644 index 000000000..6168d8e23 --- /dev/null +++ b/packages/doenetml-worker-javascript/src/ResolverAdapter.ts @@ -0,0 +1,459 @@ +import { assignDoenetMLRange } from "@doenet/utils"; +import { + addNodesToFlatFragment, + getEffectiveComponentIdx, +} from "./utils/resolver"; + +/** + * Adapter to the external (Rust) name resolver. Translates Core's + * component-tree concepts (components, replacements, composites) into the + * flat-fragment shape the resolver expects, and refreshes `core.rootNames` + * after each mutation. + * + * Stateless — the resolver lives outside this process. Holds a back-reference + * to Core to read `_components` / `componentInfoObjects`, invoke the resolver + * callbacks (`addNodesToResolver`, `deleteNodesFromResolver`, + * `calculateRootNames`), append diagnostics, and notify + * `dependencies.addBlockersFromChangedReplacements`. + */ +export class ResolverAdapter { + core: any; + + constructor({ core }: { core: any }) { + this.core = core; + } + + async addReplacementsToResolver({ + serializedReplacements, + component, + updateOldReplacementsStart, + updateOldReplacementsEnd, + blankStringReplacements, + }: { + serializedReplacements: any[]; + component: any; + updateOldReplacementsStart?: number; + updateOldReplacementsEnd?: number; + blankStringReplacements?: boolean[]; + }): Promise { + if (component.constructor.replacementsAlreadyInResolver) { + return; + } + + const { parentIdx, indexResolution } = + await this.determineParentAndIndexResolutionForResolver({ + component, + updateOldReplacementsStart, + updateOldReplacementsEnd, + blankStringReplacements, + }); + + // If `createComponentIdx` was specified, the one replacement is already in the resolver, + // so we just add its children and attribute components/references. + // Otherwise add all replacements. + const fragmentChildren: any[] = []; + let parentSourceSequence: any = null; + if (component.attributes.createComponentIdx != null) { + if (serializedReplacements[0]?.children) { + fragmentChildren.push(...serializedReplacements[0].children); + } + for (const attrName in serializedReplacements[0]?.attributes) { + const attribute = + serializedReplacements[0].attributes[attrName]; + if (attribute.type === "component") { + fragmentChildren.push(attribute.component); + } else if (attribute.type === "references") { + fragmentChildren.push(...attribute.references); + } + } + + // if the replacement that is the fragment parent has a source sequence, + // then add that as the `parentSourceSequence` of the flat fragment + let sourceSequence = + serializedReplacements[0]?.attributes["source:sequence"]; + if (sourceSequence) { + parentSourceSequence = { + type: "attribute", + name: "source:sequence", + parent: component.attributes.createComponentIdx.primitive + .number, + children: sourceSequence.children.filter( + (child: any) => typeof child === "string", + ), + sourceDoc: sourceSequence.sourceDoc, + }; + } + } else { + fragmentChildren.push(...serializedReplacements); + } + + // We add all the parent's descendants to the resolver + const flatFragment = { + children: fragmentChildren.map((child) => + typeof child === "string" + ? child + : getEffectiveComponentIdx(child), + ), + nodes: [], + parentIdx, + parentSourceSequence, + idxMap: {}, + }; + + addNodesToFlatFragment({ + flatFragment, + serializedComponents: fragmentChildren, + parentIdx, + }); + + if ( + (flatFragment.nodes.length > 0 || indexResolution !== "None") && + this.core.addNodesToResolver + ) { + this.core.addNodesToResolver(flatFragment, indexResolution); + + this.core.rootNames = this.core.calculateRootNames?.().names; + + let indexParent = + indexResolution.ReplaceAll?.parent ?? + indexResolution.ReplaceRange?.parent ?? + null; + + if ( + indexParent !== null && + indexParent !== component.componentIdx + ) { + const indexParentComposite = this.core._components[indexParent]; + + if (indexParentComposite) { + await this.core.dependencies.addBlockersFromChangedReplacements( + indexParentComposite, + ); + } + } + } + } + + async determineParentAndIndexResolutionForResolver({ + component, + updateOldReplacementsStart, + updateOldReplacementsEnd, + blankStringReplacements, + }: { + component: any; + updateOldReplacementsStart?: number; + updateOldReplacementsEnd?: number; + blankStringReplacements?: boolean[]; + }): Promise<{ parentIdx: number; indexResolution: any }> { + // If the composite was created as a child for a list, + // then the parent for resolving names is that list (the parent of the resolver). + // If `createComponentIdx` was specified, then that should be the parent for resolving names. + // Else, the composite should be the parent for resolving names. + + let update_start = updateOldReplacementsStart; + let update_end = updateOldReplacementsEnd; + + if ( + updateOldReplacementsStart !== undefined && + updateOldReplacementsEnd !== undefined + ) { + // We are replacing a range of replacement, but these include blank strings. + // Adjust the range to ignore blank strings + for (const [ + i, + isBlankString, + ] of (blankStringReplacements ?? []).entries()) { + if (i >= updateOldReplacementsEnd) { + break; + } + if (isBlankString) { + update_end!--; + if (i < updateOldReplacementsStart) { + update_start!--; + } + } + } + } + + let parentIdx: number; + + let indexResolution: any = "None"; + + if (component.doenetAttributes.forList) { + // Don't add index resolutions in this case, + // we're just adding to the children of the list, not the replacements of the list + parentIdx = component.parentIdx; + } else if (component.attributes.createComponentIdx?.primitive) { + // If `createComponentIdx` is set, then we have a copy component created from an `extend` attribute. + // That component is already in the resolver so will be the parent of the fragment added to the browser. + parentIdx = + component.attributes.createComponentIdx?.primitive.value; + + // If the component type of that parent, specified by `createComponentOfType`, is a composite, + // then it could have an index specified, so we add an index resolution + if ( + component.attributes.createComponentOfType?.primitive && + this.core.componentInfoObjects.isCompositeComponent({ + componentType: + component.attributes.createComponentOfType.primitive + .value, + includeNonStandard: true, + }) + ) { + indexResolution = { ReplaceAll: { parent: parentIdx } }; + + if (update_start !== undefined && update_end !== undefined) { + indexResolution = { + ReplaceRange: { + parent: parentIdx, + range: { start: update_start, end: update_end }, + }, + }; + } + } + } else if (component.componentType === "_copy") { + // If we have a copy that wasn't from an extend, then it was from a reference. + // Although references don't have names that can be + // Copy components are typically not part of the resolver structure and generally skipped. + // Since we don't allow direct authoring of copy components, + // they should occur only from references + + // determine if is a replacement of another type of composite + let copyComponent = component; + parentIdx = component.componentIdx; + + while (copyComponent.replacementOf) { + if (copyComponent.replacementOf.componentType === "_copy") { + copyComponent = copyComponent.replacementOf; + continue; + } else { + break; + } + } + + // now we have a copyComponent that is not a replacement of a copy + if (copyComponent.replacementOf) { + const indexParent = copyComponent.replacementOf; + + // determine where the replacement will end up being spliced in + + let start_idx: number | undefined; + let end_idx: number | undefined; + + async function calcStartEndIdx( + replacements: any[], + ): Promise { + let nonWithheldReplacements: any[] = []; + for (const repl of replacements) { + if ( + typeof repl === "string" || + !(await repl.stateValues + .isInactiveCompositeReplacement) + ) { + nonWithheldReplacements.push(repl); + } + } + + const nonBlankStringReplacements = + nonWithheldReplacements.filter( + (x) => typeof x !== "string" || x.trim() !== "", + ); + const replacementsWithoutExpandedCopies: any[] = []; + + let i = 0; + + for (const repl of nonBlankStringReplacements) { + if (repl.componentType == "_copy") { + if (!repl.isExpanded) { + if ( + repl.componentIdx === + copyComponent.componentIdx + ) { + start_idx = i; + end_idx = i + 1; + } + replacementsWithoutExpandedCopies.push(repl); + i++; + } else { + let replReplacements = repl.replacements; + if (repl.replacementsToWithhold) { + replReplacements = replReplacements.slice( + 0, + replReplacements.length - + repl.replacementsToWithhold, + ); + } + + const newReplacements = + await calcStartEndIdx(replReplacements); + const n = newReplacements.length; + + if ( + repl.componentIdx === + copyComponent.componentIdx + ) { + if ( + update_start !== undefined && + update_end !== undefined + ) { + start_idx = i + update_start; + end_idx = i + update_end; + } else { + start_idx = i; + end_idx = i + n; + } + } + + replacementsWithoutExpandedCopies.push( + ...newReplacements, + ); + i += n; + } + } else { + replacementsWithoutExpandedCopies.push(repl); + i++; + } + } + + return replacementsWithoutExpandedCopies; + } + + await calcStartEndIdx(indexParent.replacements); + + if (start_idx !== undefined && end_idx !== undefined) { + indexResolution = { + ReplaceRange: { + parent: indexParent.componentIdx, + range: { start: start_idx, end: end_idx }, + }, + }; + } else { + // if the copy was not found as a replacement of the composite, + // then it wasn't a top-level replacement and it doesn't affect the composite's index resolution + indexResolution = "None"; + } + } else { + parentIdx = copyComponent.componentIdx; + indexResolution = { ReplaceAll: { parent: parentIdx } }; + } + } else { + parentIdx = component.componentIdx; + + if ( + this.core.componentInfoObjects.isCompositeComponent({ + componentType: component.componentType, + includeNonStandard: true, + }) + ) { + if (update_start !== undefined && update_end !== undefined) { + indexResolution = { + ReplaceRange: { + parent: parentIdx, + range: { start: update_start, end: update_end }, + }, + }; + } else { + indexResolution = { ReplaceAll: { parent: parentIdx } }; + } + } + } + + return { parentIdx, indexResolution }; + } + + addComponentsToResolver(components: any[], parentIdx: number): void { + const flatFragment = { + children: components.map((child) => + typeof child === "string" + ? child + : getEffectiveComponentIdx(child), + ), + nodes: [], + parentIdx, + idxMap: {}, + }; + + addNodesToFlatFragment({ + flatFragment, + serializedComponents: components, + parentIdx, + }); + + if (this.core.addNodesToResolver) { + this.core.addNodesToResolver(flatFragment, "None"); + + this.core.rootNames = this.core.calculateRootNames?.().names; + } + } + + gatherDiagnosticsAndAssignDoenetMLRange({ + components, + diagnostics, + position, + sourceDoc, + overwriteDoenetMLRange = false, + }: { + components: any; + diagnostics: any[]; + position?: any; + sourceDoc?: number; + overwriteDoenetMLRange?: boolean; + }): void { + assignDoenetMLRange( + components, + position, + sourceDoc, + overwriteDoenetMLRange, + ); + assignDoenetMLRange(diagnostics, position, sourceDoc); + + // Add all diagnostics, preserving their existing type field + for (const diagnostic of diagnostics) { + this.core.addDiagnostic(diagnostic); + } + } + + removeComponentsFromResolver(componentsToRemove: any[]): void { + if (componentsToRemove.length === 0) { + return; + } + + const flatElements = componentsToRemove.map((comp) => { + let flatElement: any = { + type: "element", + name: comp.componentType, + parent: comp.parentIdx, + children: [], + attributes: [], + idx: comp.componentIdx, + }; + + if (comp.attributes.createComponentName && !comp.isExpanded) { + flatElement.attributes.push({ + type: "attribute", + name: "name", + parent: comp.parentIdx, + children: [ + comp.attributes.createComponentName.primitive.value, + ], + }); + } else if (comp.attributes.name) { + flatElement.attributes.push({ + type: "attribute", + name: "name", + parent: comp.parentIdx, + children: [comp.attributes.name.primitive.value], + }); + } + return flatElement; + }); + + if (this.core.deleteNodesFromResolver) { + this.core.deleteNodesFromResolver({ + nodes: flatElements, + }); + + this.core.rootNames = this.core.calculateRootNames?.().names; + } + } +} diff --git a/packages/doenetml-worker-javascript/src/StatePersistence.ts b/packages/doenetml-worker-javascript/src/StatePersistence.ts new file mode 100644 index 000000000..be30c4ee2 --- /dev/null +++ b/packages/doenetml-worker-javascript/src/StatePersistence.ts @@ -0,0 +1,149 @@ +import { serializedComponentsReplacer, data_format_version } from "@doenet/utils"; +import { set as idb_set } from "idb-keyval"; + +/** + * Owns the save-to-localStorage and save-to-database pipeline for a Core + * instance, including throttle timers and the debounced save scheduler. + * + * Holds a back-reference to Core to read `cumulativeStateVariableChanges`, + * `rendererState`, `flags`, `document`, the activity/doc/attempt IDs, and + * `coreInfoString` (set by Core during `generateDast`), and to invoke + * `reportScoreAndStateCallback`. + * + * This is purely the persistence I/O — the essential-value write engine + * that produces `cumulativeStateVariableChanges` is a separate concern + * (see `processNewStateVariableValues` in Core, slated for Phase 4). + */ +export class StatePersistence { + core: any; + saveStateToDBTimerId: ReturnType | null; + saveDocStateTimeoutID: ReturnType | null; + docStateToBeSavedToDatabase: any; + changesToBeSaved: boolean; + + constructor({ core }: { core: any }) { + this.core = core; + this.saveStateToDBTimerId = null; + this.saveDocStateTimeoutID = null; + this.docStateToBeSavedToDatabase = null; + this.changesToBeSaved = false; + } + + /** + * Schedule a debounced `saveState` after `delayMs` milliseconds, replacing + * any previously scheduled save. + */ + scheduleSave(delayMs: number): void { + if (this.saveDocStateTimeoutID !== null) { + clearTimeout(this.saveDocStateTimeoutID); + } + this.saveDocStateTimeoutID = setTimeout(() => { + this.saveState(); + }, delayMs); + } + + async saveImmediately(): Promise { + if (this.saveDocStateTimeoutID) { + // if in debounce to save doc to local storage + // then immediate save to local storage + // and override timeout to save to database + clearTimeout(this.saveDocStateTimeoutID); + await this.saveState(true); + } else { + // else override timeout to save any pending changes to database + await this.saveChangesToDatabase(true); + } + } + + async saveState( + overrideThrottle = false, + onSubmission = false, + ): Promise { + this.saveDocStateTimeoutID = null; + + const core = this.core; + + if (!core.flags.allowSaveState && !core.flags.allowLocalState) { + return; + } + + let coreStateString = JSON.stringify( + core.cumulativeStateVariableChanges, + serializedComponentsReplacer, + ); + let rendererStateString: string | null = null; + + if (core.flags.saveRendererState) { + rendererStateString = JSON.stringify( + core.rendererState, + serializedComponentsReplacer, + ); + } + + if (core.flags.allowLocalState) { + await idb_set( + `${core.activityId}|${core.docId}|${core.attemptNumber}|${core.cid}`, + { + data_format_version, + coreState: coreStateString, + rendererState: rendererStateString, + coreInfo: core.coreInfoString, + }, + ); + } + + if (!core.flags.allowSaveState) { + return; + } + + this.docStateToBeSavedToDatabase = { + cid: core.cid, + coreInfo: core.coreInfoString, + coreState: coreStateString, + rendererState: rendererStateString, + initializeCounters: core.initializeCounters, + docId: core.docId, + attemptNumber: core.attemptNumber, + activityId: core.activityId, + onSubmission, + }; + + // mark presence of changes + // so that next call to saveChangesToDatabase will save changes + this.changesToBeSaved = true; + + // if not currently in throttle, save changes to database + await this.saveChangesToDatabase(overrideThrottle); + } + + async saveChangesToDatabase(overrideThrottle = false): Promise { + // throttle save to database at 60 seconds + + if (!this.changesToBeSaved) { + return; + } + + if (this.saveStateToDBTimerId !== null) { + if (overrideThrottle) { + clearTimeout(this.saveStateToDBTimerId); + } else { + return; + } + } + + this.changesToBeSaved = false; + + // check for changes again after 60 seconds + this.saveStateToDBTimerId = setTimeout(() => { + this.saveStateToDBTimerId = null; + this.saveChangesToDatabase(); + }, 60000); + + this.core.reportScoreAndStateCallback({ + state: { ...this.docStateToBeSavedToDatabase }, + score: await this.core.document.stateValues.creditAchieved, + }); + + return; + } +} diff --git a/packages/doenetml-worker-javascript/src/StateVariableNameResolver.ts b/packages/doenetml-worker-javascript/src/StateVariableNameResolver.ts new file mode 100644 index 000000000..1ade343aa --- /dev/null +++ b/packages/doenetml-worker-javascript/src/StateVariableNameResolver.ts @@ -0,0 +1,275 @@ +// Pure string utilities for resolving state variable names: case-insensitive +// matching, public-only filtering, alias substitution, and array entry detection. +// +// Core wraps these so existing callers like +// `core.publicCaseInsensitiveAliasSubstitutions(args)` and the by-reference +// passes used in composite sugar functions keep working. + +export function findCaseInsensitiveMatches({ + stateVariables, + componentClass, + componentInfoObjects, +}: { + stateVariables: string[]; + componentClass: any; + componentInfoObjects: any; +}): string[] { + let stateVarInfo = + componentInfoObjects.stateVariableInfo[componentClass.componentType]; + + let newVariables: string[] = []; + + for (let stateVariable of stateVariables) { + let foundMatch = false; + + let lowerCaseVarName = stateVariable.toLowerCase(); + + for (let varName in stateVarInfo.stateVariableDescriptions) { + if (lowerCaseVarName === varName.toLowerCase()) { + foundMatch = true; + newVariables.push(varName); + break; + } + } + + if (foundMatch) { + continue; + } + + let isArraySize = false; + let lowerCaseNameMinusSize = lowerCaseVarName; + if (lowerCaseVarName.substring(0, 13) === "__array_size_") { + isArraySize = true; + lowerCaseNameMinusSize = lowerCaseVarName.substring(13); + } + + for (let aliasName in stateVarInfo.aliases) { + if (lowerCaseNameMinusSize === aliasName.toLowerCase()) { + // don't substitute alias here, just fix case + if (isArraySize) { + aliasName = "__array_size_" + aliasName; + } + newVariables.push(aliasName); + foundMatch = true; + break; + } + } + if (foundMatch) { + continue; + } + + let arrayEntryPrefixesLongestToShortest = Object.keys( + stateVarInfo.arrayEntryPrefixes, + ).sort((a, b) => b.length - a.length); + for (let prefix of arrayEntryPrefixesLongestToShortest) { + if ( + lowerCaseVarName.substring(0, prefix.length) === + prefix.toLowerCase() + ) { + // TODO: the varEnding is still a case-senstitive match + // Should we require that getArrayKeysFromVarName have + // a case-insensitive mode? + let arrayVariableName = + stateVarInfo.arrayEntryPrefixes[prefix].arrayVariableName; + let arrayStateVarDescription = + stateVarInfo.stateVariableDescriptions[arrayVariableName]; + let arrayKeys = + arrayStateVarDescription.getArrayKeysFromVarName({ + arrayEntryPrefix: prefix, + varEnding: stateVariable.substring(prefix.length), + numDimensions: arrayStateVarDescription.numDimensions, + }); + if (arrayKeys.length > 0) { + let newVarName = + prefix + lowerCaseVarName.substring(prefix.length); + foundMatch = true; + newVariables.push(newVarName); + break; + } + } + } + + if (foundMatch) { + continue; + } + + // no match, so don't alter + newVariables.push(stateVariable); + } + + return newVariables; +} + +export function matchPublicStateVariables({ + stateVariables, + componentClass, + componentInfoObjects, +}: { + stateVariables: string[]; + componentClass: any; + componentInfoObjects: any; +}): string[] { + let stateVarInfo = + componentInfoObjects.publicStateVariableInfo[ + componentClass.componentType + ]; + + let newVariables: string[] = []; + + for (let stateVariable of stateVariables) { + if (stateVariable in stateVarInfo.stateVariableDescriptions) { + // found public + newVariables.push(stateVariable); + continue; + } + + let varName = stateVariable; + + if (varName in stateVarInfo.aliases) { + varName = stateVarInfo.aliases[varName]; + + // check again to see if alias is public + if (varName in stateVarInfo.stateVariableDescriptions) { + // found public + newVariables.push(varName); + continue; + } + } + + let foundMatch = false; + + let arrayEntryPrefixesLongestToShortest = Object.keys( + stateVarInfo.arrayEntryPrefixes, + ).sort((a, b) => b.length - a.length); + for (let prefix of arrayEntryPrefixesLongestToShortest) { + if (varName.substring(0, prefix.length) === prefix) { + let arrayVariableName = + stateVarInfo.arrayEntryPrefixes[prefix].arrayVariableName; + let arrayStateVarDescription = + stateVarInfo.stateVariableDescriptions[arrayVariableName]; + let arrayKeys = + arrayStateVarDescription.getArrayKeysFromVarName({ + arrayEntryPrefix: prefix, + varEnding: varName.substring(prefix.length), + numDimensions: arrayStateVarDescription.numDimensions, + }); + if (arrayKeys.length > 0) { + foundMatch = true; + break; + } + } + } + + if (foundMatch) { + newVariables.push(stateVariable); + } else { + // no match, so make it a name that won't match + newVariables.push("__not_public_" + stateVariable); + } + } + + return newVariables; +} + +export function substituteAliases({ + stateVariables, + componentClass, + componentInfoObjects, +}: { + stateVariables: string[]; + componentClass: any; + componentInfoObjects: any; +}): string[] { + let newVariables: string[] = []; + + let stateVarInfo = + componentInfoObjects.stateVariableInfo[componentClass.componentType]; + + for (let stateVariable of stateVariables) { + let isArraySize = false; + if (stateVariable.substring(0, 13) === "__array_size_") { + isArraySize = true; + stateVariable = stateVariable.substring(13); + } + stateVariable = + stateVariable in stateVarInfo.aliases + ? stateVarInfo.aliases[stateVariable] + : stateVariable; + if (isArraySize) { + stateVariable = "__array_size_" + stateVariable; + } + newVariables.push(stateVariable); + } + + return newVariables; +} + +export function publicCaseInsensitiveAliasSubstitutions({ + stateVariables, + componentClass, + componentInfoObjects, +}: { + stateVariables: string[]; + componentClass: any; + componentInfoObjects: any; +}): string[] { + let mappedVarNames = findCaseInsensitiveMatches({ + stateVariables, + componentClass, + componentInfoObjects, + }); + + mappedVarNames = matchPublicStateVariables({ + stateVariables: mappedVarNames, + componentClass, + componentInfoObjects, + }); + + mappedVarNames = substituteAliases({ + stateVariables: mappedVarNames, + componentClass, + componentInfoObjects, + }); + + return mappedVarNames; +} + +export function checkIfArrayEntry({ + stateVariable, + component, +}: { + stateVariable: string; + component: any; +}): + | { + isArrayEntry: true; + arrayVariableName: string; + arrayEntryPrefix: string; + } + | { isArrayEntry: false } { + // check if stateVariable begins when an arrayEntry + for (let arrayEntryPrefix in component.arrayEntryPrefixes) { + if ( + stateVariable.substring(0, arrayEntryPrefix.length) === + arrayEntryPrefix + ) { + let arrayVariableName = + component.arrayEntryPrefixes[arrayEntryPrefix]; + let arrayStateVarObj = component.state[arrayVariableName]; + let arrayKeys = arrayStateVarObj.getArrayKeysFromVarName({ + arrayEntryPrefix, + varEnding: stateVariable.substring(arrayEntryPrefix.length), + numDimensions: arrayStateVarObj.numDimensions, + }); + if (arrayKeys.length > 0) { + return { + isArrayEntry: true, + arrayVariableName, + arrayEntryPrefix, + }; + } + } + } + + return { isArrayEntry: false }; +} diff --git a/packages/doenetml-worker-javascript/src/VisibilityTracker.ts b/packages/doenetml-worker-javascript/src/VisibilityTracker.ts new file mode 100644 index 000000000..76599b12c --- /dev/null +++ b/packages/doenetml-worker-javascript/src/VisibilityTracker.ts @@ -0,0 +1,186 @@ +type VisibilityInfo = { + componentsCurrentlyVisible: Record; + infoToSend: Record; + timeLastSent: Date; + saveDelay: number; + saveTimerId: ReturnType | null; + suspendDelay: number; + suspendTimerId: ReturnType | null; + suspended: boolean; + documentHasBeenVisible: boolean; +}; + +/** + * Tracks per-component visibility durations and emits aggregated + * `isVisible` events to the host. Owns timer state for the periodic + * "send" cycle and the auto-suspend after inactivity. + * + * Holds a back-reference to Core to push aggregated events onto + * `core.processQueue` and to call `core.onDocumentFirstVisible()` the + * first time the document becomes visible. + */ +export class VisibilityTracker { + core: any; + info: VisibilityInfo; + + constructor({ core }: { core: any }) { + this.core = core; + this.info = { + componentsCurrentlyVisible: {}, + infoToSend: {}, + timeLastSent: new Date(), + saveDelay: 60000, + saveTimerId: null, + suspendDelay: 3 * 60000, + suspendTimerId: null, + suspended: false, + documentHasBeenVisible: false, + }; + } + + processVisibilityChangedEvent(event: any): void { + let componentIdx = event.object.componentIdx; + let isVisible = event.result.isVisible; + + if (isVisible) { + if (!this.info.componentsCurrentlyVisible[componentIdx]) { + this.info.componentsCurrentlyVisible[componentIdx] = new Date(); + } + if (componentIdx === this.core.documentIdx) { + if (!this.info.documentHasBeenVisible) { + this.info.documentHasBeenVisible = true; + this.core.onDocumentFirstVisible(); + } + } + } else { + let begin = this.info.componentsCurrentlyVisible[componentIdx]; + if (begin) { + delete this.info.componentsCurrentlyVisible[componentIdx]; + + let timeInSeconds = + (new Date().getTime() - + Math.max( + begin.getTime(), + this.info.timeLastSent.getTime(), + )) / + 1000; + + if (this.info.infoToSend[componentIdx]) { + this.info.infoToSend[componentIdx] += timeInSeconds; + } else { + this.info.infoToSend[componentIdx] = timeInSeconds; + } + } + } + } + + sendVisibilityChangedEvents(): Promise | undefined { + let infoToSend: Record = { ...this.info.infoToSend }; + this.info.infoToSend = {}; + let timeLastSent = this.info.timeLastSent; + this.info.timeLastSent = new Date(); + let currentVisible: Record = { + ...this.info.componentsCurrentlyVisible, + }; + + for (const componentIdxStr in currentVisible) { + let timeInSeconds = + (this.info.timeLastSent.getTime() - + Math.max( + timeLastSent.getTime(), + currentVisible[componentIdxStr].getTime(), + )) / + 1000; + if (infoToSend[componentIdxStr]) { + infoToSend[componentIdxStr] += timeInSeconds; + } else { + infoToSend[componentIdxStr] = timeInSeconds; + } + } + + for (const componentIdxStr in infoToSend) { + infoToSend[componentIdxStr] = Math.round( + infoToSend[componentIdxStr], + ); + if (!infoToSend[componentIdxStr]) { + // delete if rounded down to zero + delete infoToSend[componentIdxStr]; + } + } + + let promise: Promise | undefined; + + if (Object.keys(infoToSend).length > 0) { + let event = { + object: { + componentIdx: this.core.documentIdx, + componentType: "document", + }, + verb: "isVisible", + result: infoToSend, + }; + + promise = new Promise((resolve, reject) => { + this.core.processQueue.push({ + type: "recordEvent", + event, + resolve, + reject, + }); + + if (!this.core.processing) { + this.core.processing = true; + this.core.executeProcesses(); + } + }); + } + + if (!this.info.suspended) { + if (this.info.saveTimerId !== null) { + clearTimeout(this.info.saveTimerId); + } + this.info.saveTimerId = setTimeout( + () => this.sendVisibilityChangedEvents(), + this.info.saveDelay, + ); + } + + return promise; + } + + async suspendVisibilityMeasuring(): Promise { + if (this.info.saveTimerId !== null) { + clearTimeout(this.info.saveTimerId); + } + if (this.info.suspendTimerId !== null) { + clearTimeout(this.info.suspendTimerId); + } + if (!this.info.suspended) { + this.info.suspended = true; + await this.sendVisibilityChangedEvents(); + } + } + + resumeVisibilityMeasuring(): void { + if (this.info.suspended) { + // restart visibility measuring + this.info.suspended = false; + this.info.timeLastSent = new Date(); + if (this.info.saveTimerId !== null) { + clearTimeout(this.info.saveTimerId); + } + this.info.saveTimerId = setTimeout( + () => this.sendVisibilityChangedEvents(), + this.info.saveDelay, + ); + } + + if (this.info.suspendTimerId !== null) { + clearTimeout(this.info.suspendTimerId); + } + this.info.suspendTimerId = setTimeout( + () => this.suspendVisibilityMeasuring(), + this.info.suspendDelay, + ); + } +} From 45050f627362797522754ee832c8e7593048e79e Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Fri, 1 May 2026 14:15:01 -0500 Subject: [PATCH 2/7] docs: add CLAUDE.md with codebase guidance Co-Authored-By: Claude Haiku 4.5 --- CLAUDE.md | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..73815e200 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,174 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## High-Level Architecture + +DoenetML is a semantic markup language for building interactive web activities. The system has three layers: + +### Layer 1: Parsing (Input) +The **parser** (`packages/parser`) converts DoenetML XML into a **DAST** (Document Abstract Syntax Tree). It handles XML parsing, validation, and normalization. Key exports: `stringToLezer()`, `lezerToDast()`, `normalizeDocumentDast()`. + +### Layer 2: Computation (Worker) +The **worker** (`packages/doenetml-worker`) runs in a Web Worker and manages document state and computation. It combines: +- **JavaScript logic** (`packages/doenetml-worker-javascript`) for state updates and interactions +- **Rust/WASM logic** (`packages/doenetml-worker-rust/lib-js-wasm-binding`) The worker is slowly being transitioned to Rust. For the main `packages/doenetml` component, just small pieces of Rust (e.g., reference resolution) are currently invoked. + +Communication between main thread and worker uses structured messages. The worker is responsible for evaluating components, tracking dependencies (DAG), and managing variants. + +### Layer 3: UI (Viewer/Editor) +The **main component** (`packages/doenetml/src/doenetml.tsx`) exports two top-level React components: +- **`DoenetViewer`** — read-only rendering of DoenetML +- **`DoenetEditor`** — editor UI with live preview + +Both use Redux for state management (`packages/doenetml/src/state/`) and share a Web Worker instance. + +### Connected Packages +- **`@doenet/prefigure`** — backend for executing Python/computation-heavy activities +- **`@doenet/standalone`, `@doenet/doenetml-iframe`** — bundled variants of the main library for different hosting scenarios +- **`@doenet/codemirror`** — code editor integration +- **`@doenet/ui-components`** — reusable UI components (used across doenetml, prefigure, etc.) +- **`packages/vscode-extension`** — VS Code extension with LSP support (`packages/lsp`) + +## Monorepo Structure + +This is an npm workspace monorepo. Key points: +- All packages build via **Vite** and **Wireit** (a task orchestration tool that manages build dependencies) +- **Wireit** is configured in each `package.json`'s `wireit` field; it automatically rebuilds dependencies when inputs change +- Each package can be built/tested independently with `-w ` or `-w @scope/package-name` flags + +## Build & Development Commands + +### Daily Development + +```bash +# Start dev server (port 8012, builds doenetml and dependencies) +npm run dev + +# Build a single package (rebuilds dependencies automatically via wireit) +npm run build -w @doenet/doenetml + +# Build all packages (one-shot, no watch) +npm run build:all + +# Format code with Prettier (required before commits) +npm run prettier:format + +# Check formatting +npm run prettier:check +``` + +### Testing + +```bash +# Run all tests (Vitest only, very slow) +npm run test + +# Run Vitest only (all packages except worker) +npm run test:all-no-worker-js -- run + +# Run targeted Vitest (e.g., prefigure package) +npm run test -w @doenet/prefigure -- --run test/index-api.test.ts + +# Run Cypress e2e tests in groups (recommended) +npm run test:e2e-group1 +npm run test:e2e-group2 +npm run test:e2e-group3 +npm run test:e2e-group4 +npm run test:e2e-group5 +npm run test:codemirror-cypress + +# Run a single Cypress spec (fast-fail mode) +npm run test-cypress-fast-fail -w @doenet/test-cypress -- --config specPattern=cypress/e2e/tagSpecific/choiceinput.cy.js +``` + +**Important**: After code changes affecting Cypress, rebuild `@doenet/test-cypress` before running Cypress tests. See `TEST_RUN_INSTRUCTIONS_FOR_AGENTS.md` for the full runbook. + +## Coding Conventions + +From `AGENTS.md`; treat these as requirements: + +- **No `private` class fields.** Use an underscore prefix for internal members (`_field`). +- **No fire-and-forget promises.** Always attach `.catch()` to intentionally unawaited Promises, or use `async`/`await`. +- **Prefer function declarations over function-valued variables** (`function foo() {}` over `const foo = ()`), unless reassignment is needed. +- **Prefer `async`/`await`** over `.then()` chains. +- **Format with Prettier** before committing: `npm run prettier:format` + +## Files to Avoid Committing + +From `AGENTS.md`: + +- `packages/doenetml/dev/testCode.doenet` — local development file +- `packages/doenetml/dev/main.tsx` — local development file +- Untracked `*.md` files in the repository root — planning/notes files + +If you edit these during development, they will show as modified but should not be staged. + +## Changesets & Publishing + +The repo uses Changesets for version management. Configuration is in `.changeset/config.json`. + +### Fixed Group (synchronized versioning) +Six packages version together: +- `@doenet/doenetml`, `@doenet/standalone`, `@doenet/doenetml-iframe` +- `@doenet/v06-to-v07`, `@doenet/vscode-extension`, `doenet-vscode-extension` + +### Independent Versioning +- `@doenet/prefigure` versions independently + +**Rule**: When creating a changeset for a user-facing change, include **all packages** where the change is visible to end users, not just where the implementation lives. + +Example: A change to `packages/doenetml/src/Viewer` affects `@doenet/doenetml`, `@doenet/standalone`, `@doenet/doenetml-iframe`, and downstream variants. Include all in the changeset. + +## GitHub & Remotes + +The repository may use a personal fork as `origin` with the canonical `Doenet/DoenetML` as `upstream`. + +- **Always base PRs on `upstream/main`**, not `origin/main` +- Push your branch to your fork (`origin`), then create the PR targeting `Doenet/DoenetML:main` +- Use the GitHub CLI: `gh pr create --repo Doenet/DoenetML --base main --head :` + +## Key State & Data Flow + +### Redux Store Structure +Located in `packages/doenetml/src/state/`: +- **`main` slice** — document state, component data, update queue +- **`keyboard` slice** — virtual keyboard focus tracking + +Components dispatch actions to update UI state; the worker listens for changes and updates document computation. + +### Worker Communication +The worker receives serialized updates and returns rendered component states. Redux selectors provide derived state to UI components. + +## Testing Strategy + +- **Vitest** for unit tests, component logic, and utility functions (files: `*.test.ts`, `*.test.tsx`) +- **Cypress** for e2e tests, user interactions, and full rendering (files: `cypress/e2e/*.cy.js`) +- Tests are grouped; run by group number to parallelize CI + +## Common Tasks + +### Add a new component type +1. Implement the **worker logic** in `packages/doenetml-worker-javascript` +2. Implement the **UI renderer** in `packages/doenetml/src/Viewer/renderers` or similar +3. Build the schema +4. Add **tests** in both Vitest and Cypress +5. Add a **changeset** if user-facing + +### Debug a rendering issue +1. Start `npm run dev` and inspect the browser console +2. Use Redux DevTools to inspect state changes +3. Run `npm run test -w @doenet/test-cypress` to verify e2e tests still pass + +### Run docs locally +```bash +npm run docs +``` + +Builds prerequisites and serves docs (Nextra-based) at `http://localhost:3000`. + +## Performance & Notes + +- The worker runs heavy computations off the main thread; avoid blocking UI updates +- Large documents or deeply nested variants can cause lag; consider profiling with DevTools +- Rust/WASM changes require a full rebuild of `packages/doenetml-worker-rust`; Wireit handles this but can be slow the first time From 36d6d30b54ad1c35aee4cfeee96499bd3e599b6d Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Fri, 1 May 2026 14:28:52 -0500 Subject: [PATCH 3/7] prettier --- packages/doenetml-worker-javascript/src/ResolverAdapter.ts | 7 +++---- .../doenetml-worker-javascript/src/StatePersistence.ts | 5 ++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/doenetml-worker-javascript/src/ResolverAdapter.ts b/packages/doenetml-worker-javascript/src/ResolverAdapter.ts index 6168d8e23..7dbdfecb9 100644 --- a/packages/doenetml-worker-javascript/src/ResolverAdapter.ts +++ b/packages/doenetml-worker-javascript/src/ResolverAdapter.ts @@ -159,10 +159,9 @@ export class ResolverAdapter { ) { // We are replacing a range of replacement, but these include blank strings. // Adjust the range to ignore blank strings - for (const [ - i, - isBlankString, - ] of (blankStringReplacements ?? []).entries()) { + for (const [i, isBlankString] of ( + blankStringReplacements ?? [] + ).entries()) { if (i >= updateOldReplacementsEnd) { break; } diff --git a/packages/doenetml-worker-javascript/src/StatePersistence.ts b/packages/doenetml-worker-javascript/src/StatePersistence.ts index be30c4ee2..3bb13daf3 100644 --- a/packages/doenetml-worker-javascript/src/StatePersistence.ts +++ b/packages/doenetml-worker-javascript/src/StatePersistence.ts @@ -1,4 +1,7 @@ -import { serializedComponentsReplacer, data_format_version } from "@doenet/utils"; +import { + serializedComponentsReplacer, + data_format_version, +} from "@doenet/utils"; import { set as idb_set } from "idb-keyval"; /** From 3d8d9d0f8fa9461706f8fa7319e60f4936b3f554 Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Fri, 1 May 2026 14:44:21 -0500 Subject: [PATCH 4/7] refactor(worker-javascript): extract Phase 2 helpers from Core.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the Core.js breakup with six moderate-effort modules. Same pattern as Phase 1 (composed sibling holding a `core` back-reference, thin delegating wrapper on Core for every public method/property). No behavior change. Modules extracted: - RendererInstructionBuilder.ts — owns componentsToRender, componentsWithChangedChildrenToRender, rendererState; the dast instruction stream sent to the renderer - ProcessQueue.ts — owns processQueue, processing, stopProcessingRequests; async request queue and entry-point scheduling (executeProcesses, requestAction, requestUpdate, requestRecordEvent) - ComponentLifecycle.ts — stateless: registration, ancestors, defining-child splicing, propagation to shadows - ChildMatcher.ts — child-group matching, adapter substitution, rendered-child filtering (recursion guard only) - DeletionEngine.ts — stateless two-phase component deletion - ActionTriggerScheduler.ts — owns stateVariableChangeTriggers, actionsChangedToActions, originsOfActionsChangedToActions; trigger polling and chained-action graph Core.js drops from 12,909 to 11,253 lines (this PR), 13,837 → 11,253 since the refactor began (~18.7%). Co-Authored-By: Claude Haiku 4.5 --- .../src/ActionTriggerScheduler.ts | 303 +++ .../src/ChildMatcher.ts | 483 ++++ .../src/ComponentLifecycle.ts | 264 ++ .../doenetml-worker-javascript/src/Core.js | 2186 ++--------------- .../src/DeletionEngine.ts | 397 +++ .../src/ProcessQueue.ts | 220 ++ .../src/RendererInstructionBuilder.ts | 512 ++++ 7 files changed, 2444 insertions(+), 1921 deletions(-) create mode 100644 packages/doenetml-worker-javascript/src/ActionTriggerScheduler.ts create mode 100644 packages/doenetml-worker-javascript/src/ChildMatcher.ts create mode 100644 packages/doenetml-worker-javascript/src/ComponentLifecycle.ts create mode 100644 packages/doenetml-worker-javascript/src/DeletionEngine.ts create mode 100644 packages/doenetml-worker-javascript/src/ProcessQueue.ts create mode 100644 packages/doenetml-worker-javascript/src/RendererInstructionBuilder.ts diff --git a/packages/doenetml-worker-javascript/src/ActionTriggerScheduler.ts b/packages/doenetml-worker-javascript/src/ActionTriggerScheduler.ts new file mode 100644 index 000000000..32bef9752 --- /dev/null +++ b/packages/doenetml-worker-javascript/src/ActionTriggerScheduler.ts @@ -0,0 +1,303 @@ +/** + * Tracks state-variable-driven action triggers and the chained-action + * graph. Each cycle, `processStateVariableTriggers` polls registered + * trigger variables; `triggerChainedActions` walks the chained-action + * graph after a state mutation and dispatches the downstream actions. + * + * Owns: + * - `stateVariableChangeTriggers`: per-component, per-state-variable + * "fire this action when the value changes" registrations + * - `actionsChangedToActions`: source-id → array of follow-up actions + * - `originsOfActionsChangedToActions`: bookkeeping that lets us find + * and unregister chained actions when their declaring state variable + * re-resolves to a different target list + * + * Holds a back-reference to Core to read `_components` and `updateInfo` + * and to dispatch via `performAction` and `updateAllChangedRenderers`. + */ +export class ActionTriggerScheduler { + core: any; + stateVariableChangeTriggers: Record< + number, + Record + >; + actionsChangedToActions: Record; + originsOfActionsChangedToActions: Record>; + + constructor({ core }: { core: any }) { + this.core = core; + this.stateVariableChangeTriggers = {}; + this.actionsChangedToActions = {}; + this.originsOfActionsChangedToActions = {}; + } + + /** + * Clear all per-document trigger and chain registrations. Called from + * `Core.generateDast` so state from any previous run does not leak in. + */ + reset(): void { + this.stateVariableChangeTriggers = {}; + this.actionsChangedToActions = {}; + this.originsOfActionsChangedToActions = {}; + } + + async processStateVariableTriggers( + updateRenderersIfTriggered: boolean = false, + ): Promise { + // TODO: can we make this more efficient by only checking components that changed? + // componentsToUpdateRenderers is close, but it includes only rendered components + // and we could have components with triggers that are not rendered + + let triggeredAction = false; + + for (const componentIdxStr in this.stateVariableChangeTriggers) { + const componentIdx = Number(componentIdxStr); + let component = this.core._components[componentIdx]; + for (let stateVariable in this.stateVariableChangeTriggers[ + componentIdx + ]) { + let triggerInstructions = + this.stateVariableChangeTriggers[componentIdx][ + stateVariable + ]; + + let value = await component.state[stateVariable].value; + + if (value !== triggerInstructions.previousValue) { + let previousValue = triggerInstructions.previousValue; + triggerInstructions.previousValue = value; + let action = component.actions[triggerInstructions.action]; + if (action) { + await this.core.performAction({ + componentIdx, + actionName: triggerInstructions.action, + args: { + stateValues: { [stateVariable]: value }, + previousValues: { + [stateVariable]: previousValue, + }, + skipRendererUpdate: true, + }, + }); + triggeredAction = true; + } + } + } + } + + if (triggeredAction && updateRenderersIfTriggered) { + await this.core.updateAllChangedRenderers(); + } + } + + recordStateVariablesMustEvaluate(componentIdx: number): void { + let comp = this.core._components[componentIdx]; + + for (let vName in comp.state) { + if (comp.state[vName].mustEvaluate) { + this.core.updateInfo.stateVariablesToEvaluate.push({ + componentIdx, + stateVariable: vName, + }); + } + } + } + + async checkForActionChaining({ + component, + stateVariables, + }: { + component: any; + stateVariables?: string[]; + }): Promise { + if (!component) { + return; + } + + if (!stateVariables) { + stateVariables = Object.keys(component.state); + } + + for (let varName of stateVariables) { + let stateVarObj = component.state[varName]; + + if (stateVarObj.chainActionOnActionOfStateVariableTargets) { + let chainInfo = + stateVarObj.chainActionOnActionOfStateVariableTargets; + let targetIds = await stateVarObj.value; + + let originObj = + this.originsOfActionsChangedToActions[ + component.componentIdx + ]; + + let previousIds: any[] | undefined; + if (originObj) { + previousIds = originObj[varName]; + } + + if (!previousIds) { + previousIds = []; + } + + let newTargets: any[] = []; + + if (Array.isArray(targetIds)) { + newTargets = [...targetIds]; + for (let id of newTargets) { + let indPrev = previousIds.indexOf(id); + + if (indPrev === -1) { + // found a component/action that wasn't previously chained + let componentActionsChained = + this.actionsChangedToActions[id]; + if (!componentActionsChained) { + componentActionsChained = + this.actionsChangedToActions[id] = []; + } + + componentActionsChained.push({ + componentIdx: component.componentIdx, + actionName: chainInfo.triggeredAction, + stateVariableDefiningChain: varName, + args: {}, + }); + } else { + // target was already chained + // remove from previous names to indicate it should still be chained + previousIds.splice(indPrev, 1); + } + } + } + + // if any ids are left in previousIds, + // then they should no longer be chained + for (let idToNoLongerChain of previousIds) { + let componentActionsChained = + this.actionsChangedToActions[idToNoLongerChain]; + if (componentActionsChained) { + let newComponentActionsChained: any[] = []; + + for (let chainedInfo of componentActionsChained) { + if ( + chainedInfo.componentIdx !== + component.componentIdx || + chainedInfo.stateVariableDefiningChain !== + varName + ) { + newComponentActionsChained.push(chainedInfo); + } + } + + this.actionsChangedToActions[idToNoLongerChain] = + newComponentActionsChained; + } + } + + if (newTargets.length > 0) { + if (!originObj) { + originObj = this.originsOfActionsChangedToActions[ + component.componentIdx + ] = {}; + } + originObj[varName] = newTargets; + } else if (originObj) { + delete originObj[varName]; + + if (Object.keys(originObj).length === 0) { + delete this.originsOfActionsChangedToActions[ + component.componentIdx + ]; + } + } + } + } + } + + async triggerChainedActions({ + componentIdx, + triggeringAction, + actionId, + sourceInformation = {}, + skipRendererUpdate = false, + }: { + componentIdx: number; + triggeringAction?: string; + actionId?: string; + sourceInformation?: any; + skipRendererUpdate?: boolean; + }): Promise { + for (const cIdxStr in this.core.updateInfo + .componentsToUpdateActionChaining) { + await this.checkForActionChaining({ + component: this.core.components[cIdxStr], + stateVariables: + this.core.updateInfo.componentsToUpdateActionChaining[ + cIdxStr + ], + }); + } + + this.core.updateInfo.componentsToUpdateActionChaining = {}; + + let actionsToChain: any[] = []; + + let cIdx = componentIdx; + + while (true) { + let comp = this.core._components[cIdx]; + let id: string | number = cIdx; + + if (triggeringAction) { + id = id + "|" + triggeringAction; + } + + if (this.actionsChangedToActions[id]) { + actionsToChain.push(...this.actionsChangedToActions[id]); + } + + if (comp?.shadows) { + let composite = + this.core._components[comp.shadows.compositeIdx]; + if (composite.attributes.createComponentOfType != null) { + break; + } + + // We propagate to shadows if the component was copied with a bare references such as `$P` + // but not if was copied via extend/copy attribute, such as ` + // Rationale: + // If we include $P in a graph, + // then triggerWhenObjectsClicked="$P" and triggerWhenObjectsFocused="$P" + // will be triggered by that reference, which is what authors would expect. + // Another use case is defining an , + // along with other triggered actions using triggerWith="$uv", + // inside a and then including a $uv + // where we want the button to be. + + cIdx = comp.shadows.componentIdx; + } else { + break; + } + } + + for (let chainedActionInstructions of actionsToChain) { + chainedActionInstructions = { ...chainedActionInstructions }; + if (chainedActionInstructions.args) { + chainedActionInstructions.args = { + ...chainedActionInstructions.args, + }; + } else { + chainedActionInstructions.args = {}; + } + chainedActionInstructions.args.skipRendererUpdate = true; + await this.core.performAction(chainedActionInstructions); + } + + if (!skipRendererUpdate) { + await this.core.updateAllChangedRenderers( + sourceInformation, + actionId, + ); + } + } +} diff --git a/packages/doenetml-worker-javascript/src/ChildMatcher.ts b/packages/doenetml-worker-javascript/src/ChildMatcher.ts new file mode 100644 index 000000000..be91715f2 --- /dev/null +++ b/packages/doenetml-worker-javascript/src/ChildMatcher.ts @@ -0,0 +1,483 @@ +import { assignDoenetMLRange } from "@doenet/utils"; + +/** + * Resolves a parent's defining children into matched, adapted, ordered + * `activeChildren` and bookkeeping in `allChildren` / `allChildrenOrdered`, + * including substituting adapters when a child cannot be matched directly + * to a parent's child group, and computing which active children should + * actually be rendered. + * + * Stateless apart from a recursion guard (`derivingChildResultsInProgress`). + * Holds a back-reference to Core to read `_components`, + * `componentInfoObjects`, `dependencies`, `updateInfo`, and to invoke + * Phase-3 expansion methods (`expandCompositeOfDefiningChildren`, + * `replaceCompositeChildren`, `expandCompositeComponent`, + * `createIsolatedComponents`) and append to `unmatchedChildren`. + */ +export class ChildMatcher { + core: any; + derivingChildResultsInProgress: number[]; + + constructor({ core }: { core: any }) { + this.core = core; + this.derivingChildResultsInProgress = []; + } + + async deriveChildResultsFromDefiningChildren({ + parent, + expandComposites = true, + forceExpandComposites = false, + }: { + parent: any; + expandComposites?: boolean; + forceExpandComposites?: boolean; + }): Promise { + if ( + this.derivingChildResultsInProgress.includes(parent.componentIdx) + ) { + return { success: false, skipping: true }; + } + this.derivingChildResultsInProgress.push(parent.componentIdx); + + // create allChildren and activeChildren from defining children + // apply child logic and substitute adapters to modify activeChildren + + // attempt to expand composites before modifying active children + let result = await this.core.expandCompositeOfDefiningChildren( + parent, + parent.definingChildren, + expandComposites, + forceExpandComposites, + ); + parent.unexpandedCompositesReady = result.unexpandedCompositesReady; + parent.unexpandedCompositesNotReady = + result.unexpandedCompositesNotReady; + + let previousActiveChildren: any[] | undefined; + + if (parent.activeChildren) { + previousActiveChildren = parent.activeChildren.map((child: any) => + child.componentIdx ? child.componentIdx : child, + ); + } + + parent.activeChildren = parent.definingChildren.slice(); // shallow copy + + // allChildren include activeChildren, definingChildren, + // and possibly some children that are neither + // (which could occur when a composite is expanded and the result is adapted) + // ignores string and number primitive children + parent.allChildren = {}; + + // allChildrenOrdered contains same children as allChildren, + // but retaining an order that we can use for counters. + // If defining children are replaced my composite replacements or adapters, + // those children will come immediately after the corresponding defining child + parent.allChildrenOrdered = []; + + for (let ind = 0; ind < parent.activeChildren.length; ind++) { + let child = parent.activeChildren[ind]; + let childIdx; + if (typeof child !== "object") { + continue; + } + + childIdx = child.componentIdx; + + parent.allChildren[childIdx] = { + activeChildrenIndex: ind, + definingChildrenIndex: ind, + component: child, + }; + + parent.allChildrenOrdered.push(childIdx); + } + + // if any of activeChildren are expanded compositeComponents + // replace with new components given by the composite component + await this.core.replaceCompositeChildren(parent); + + let childGroupResults = await this.matchChildrenToChildGroups(parent); + + if (childGroupResults.success) { + delete this.core.unmatchedChildren[parent.componentIdx]; + parent.childrenMatchedWithPlaceholders = true; + parent.matchedCompositeChildrenWithPlaceholders = true; + } else { + parent.childrenMatchedWithPlaceholders = false; + parent.matchedCompositeChildrenWithPlaceholders = true; + + let unmatchedChildrenTypes: string[] = []; + for (let child of childGroupResults.unmatchedChildren) { + if (typeof child === "string") { + unmatchedChildrenTypes.push("string"); + } else { + unmatchedChildrenTypes.push( + "`<" + child.componentType + ">`", + ); + if ( + this.core.componentInfoObjects.isInheritedComponentType( + { + inheritedComponentType: child.componentType, + baseComponentType: "_composite", + }, + ) + ) { + parent.matchedCompositeChildrenWithPlaceholders = false; + } + } + } + + if (parent.doenetAttributes.isAttributeChildFor) { + let attributeForComponentType = + parent.ancestors[0].componentClass.componentType; + this.core.unmatchedChildren[parent.componentIdx] = { + message: `Invalid format for attribute ${parent.doenetAttributes.isAttributeChildFor} of \`<${attributeForComponentType}>\`.`, + }; + } else { + this.core.unmatchedChildren[parent.componentIdx] = { + message: `Invalid children for \`<${ + parent.componentType + }>\`: Found invalid children: ${unmatchedChildrenTypes.join( + ", ", + )}`, + }; + } + } + + await this.core.dependencies.addBlockersFromChangedActiveChildren({ + parent, + }); + + let ind = this.derivingChildResultsInProgress.indexOf( + parent.componentIdx, + ); + + this.derivingChildResultsInProgress.splice(ind, 1); + + if (parent.constructor.renderChildren) { + let childrenUnchanged = + previousActiveChildren && + previousActiveChildren.length == parent.activeChildren.length && + parent.activeChildren.every((child: any, ind: number) => + child.componentIdx + ? child.componentIdx === previousActiveChildren![ind] + : child === previousActiveChildren![ind], + ); + if (!childrenUnchanged) { + this.core.componentsWithChangedChildrenToRender.add( + parent.componentIdx, + ); + } + } + + return childGroupResults; + } + + async matchChildrenToChildGroups(parent: any): Promise { + parent.childMatchesByGroup = {}; + + for (let groupName in parent.constructor.childGroupIndsByName) { + parent.childMatchesByGroup[groupName] = []; + } + + let success = true; + + let unmatchedChildren: any[] = []; + + for (let [ind, child] of ( + parent.activeChildren.entries() as Iterable<[number, any]> + )) { + let childType = + typeof child !== "object" ? typeof child : child.componentType; + + if (childType === undefined) { + success = false; + unmatchedChildren.push(child); + continue; + } + + let result = this.findChildGroup(childType, parent.constructor); + + if (result.success) { + parent.childMatchesByGroup[result.group!].push(ind); + + if (result.adapterIndUsed !== undefined) { + await this.substituteAdapter({ + parent, + childInd: ind, + adapterIndUsed: result.adapterIndUsed, + }); + } + } else { + success = false; + unmatchedChildren.push(child); + } + } + + return { success, unmatchedChildren }; + } + + findChildGroup( + childType: string, + parentClass: any, + ): { + success: boolean; + group?: string; + adapterIndUsed?: number; + } { + let result = this.findChildGroupNoAdapters(childType, parentClass); + + if (result.success) { + return result; + } else if (childType === "string") { + return { success: false }; + } + + // check if can match with adapters + let childClass = + this.core.componentInfoObjects.allComponentClasses[childType]; + + // if didn't match child, attempt to match with child's adapters + let numAdapters = childClass.numAdapters; + + for (let n = 0; n < numAdapters; n++) { + let adapterComponentType = childClass.getAdapterComponentType( + n, + this.core.componentInfoObjects.publicStateVariableInfo, + ); + + result = this.findChildGroupNoAdapters( + adapterComponentType, + parentClass, + ); + + if (result.success) { + (result as any).adapterIndUsed = n; + return result; + } + } + + // lastly try to match with afterAdapters set to true + return this.findChildGroupNoAdapters(childType, parentClass, true); + } + + findChildGroupNoAdapters( + componentType: string, + parentClass: any, + afterAdapters: boolean = false, + ): { success: boolean; group?: string } { + if (parentClass.childGroupOfComponentType[componentType]) { + return { + success: true, + group: parentClass.childGroupOfComponentType[componentType], + }; + } + + for (let group of parentClass.childGroups) { + for (let typeFromGroup of group.componentTypes) { + if ( + this.core.componentInfoObjects.isInheritedComponentType({ + inheritedComponentType: componentType, + baseComponentType: typeFromGroup, + }) + ) { + if (group.matchAfterAdapters && !afterAdapters) { + continue; + } + // don't match composites to the base component + // so that they will expand + if ( + !( + typeFromGroup === "_base" && + this.core.componentInfoObjects.isInheritedComponentType( + { + inheritedComponentType: componentType, + baseComponentType: "_composite", + }, + ) + ) + ) { + parentClass.childGroupOfComponentType[componentType] = + group.group; + + return { + success: true, + group: group.group, + }; + } + } + } + } + + return { success: false }; + } + + async returnActiveChildrenIndicesToRender(component: any): Promise { + let indicesToRender: number[] = []; + let numChildrenToRender = Infinity; + if ("numChildrenToRender" in component.state) { + numChildrenToRender = + await component.stateValues.numChildrenToRender; + } + let childIndicesToRender: number[] | null = null; + if ("childIndicesToRender" in component.state) { + childIndicesToRender = + await component.stateValues.childIndicesToRender; + } + + for (let [ind, child] of ( + component.activeChildren.entries() as Iterable<[number, any]> + )) { + if (ind >= numChildrenToRender) { + break; + } + + if (childIndicesToRender && !childIndicesToRender.includes(ind)) { + continue; + } + + if (typeof child === "object") { + if ( + child.constructor.sendToRendererEvenIfHidden || + !(await child.stateValues.hidden) + ) { + indicesToRender.push(ind); + } + } else { + // if have a primitive, + // will be hidden if a composite source is hidden + let hidden = false; + if (component.compositeReplacementActiveRange) { + for (let compositeInfo of component.compositeReplacementActiveRange) { + let composite = + this.core._components[compositeInfo.compositeIdx]; + if (await composite.stateValues.hidden) { + if ( + compositeInfo.firstInd <= ind && + compositeInfo.lastInd >= ind + ) { + hidden = true; + break; + } + } + } + } + if (!hidden) { + indicesToRender.push(ind); + } + } + } + + return indicesToRender; + } + + async substituteAdapter({ + parent, + childInd, + adapterIndUsed, + }: { + parent: any; + childInd: number; + adapterIndUsed: number; + }): Promise { + // replace activeChildren with their adapters + + let originalChild = parent.activeChildren[childInd]; + + let newSerializedChild: any; + if (originalChild.componentIdx != undefined) { + newSerializedChild = originalChild.getAdapter(adapterIndUsed); + newSerializedChild.componentIdx = this.core._components.length; + this.core._components[this.core._components.length] = undefined; + } else { + // XXX: how does this work with the new componentIdx approach? + + // child isn't a component, just an object with a componentType + // Create an object that is just the componentType of the adapter + newSerializedChild = { + componentType: + this.core.componentInfoObjects.allComponentClasses[ + originalChild.componentType + ].getAdapterComponentType( + adapterIndUsed, + this.core.componentInfoObjects.publicStateVariableInfo, + ), + placeholderInd: originalChild.placeholderInd + "adapt", + }; + } + + let adapter = originalChild.adapterUsed; + + if ( + adapter === undefined || + adapter.componentType !== newSerializedChild.componentType + ) { + if (originalChild.componentIdx != undefined) { + newSerializedChild.adaptedFrom = originalChild.componentIdx; + assignDoenetMLRange( + [newSerializedChild], + originalChild.position, + originalChild.sourceDoc, + ); + let newChildrenResult = + await this.core.createIsolatedComponents({ + serializedComponents: [newSerializedChild], + shadow: true, + ancestors: originalChild.ancestors, + }); + + adapter = newChildrenResult.components[0]; + } else { + // XXX: how does this work with the new componentIdx approach? + + // didn't have a component for the original child, just a componentType + // Adapter will also just be the componentType returned from childmatches + newSerializedChild.adaptedFrom = originalChild; + adapter = newSerializedChild; + } + } + + // Replace originalChild with its adapter in activeChildren + parent.activeChildren.splice(childInd, 1, adapter); + + // TODO: if originalChild is a placeholder, we lose track of it + // (other than through adaptedFrom of adapted) + // once we splice it out of activeChildren. Is that a problem? + + // Update allChildren to show that originalChild is no longer active + // and that adapter is now an active child + if (originalChild.componentIdx != undefined) { + // ignore placeholder active children + delete parent.allChildren[originalChild.componentIdx] + .activeChildrenIndex; + parent.allChildren[adapter.componentIdx] = { + activeChildrenIndex: childInd, + component: adapter, + }; + } + + // find index of originalChild in allChildrenOrdered + // and place adapter immediately afterward + if (originalChild.componentIdx != undefined) { + let originalInd = parent.allChildrenOrdered.indexOf( + originalChild.componentIdx, + ); + parent.allChildrenOrdered.splice( + originalInd + 1, + 0, + adapter.componentIdx, + ); + } else { + // adapter of placeholder + let originalInd = parent.allChildrenOrdered.indexOf( + originalChild.placeholderInd, + ); + parent.allChildrenOrdered.splice( + originalInd + 1, + 0, + adapter.placeholderInd, + ); + } + } +} diff --git a/packages/doenetml-worker-javascript/src/ComponentLifecycle.ts b/packages/doenetml-worker-javascript/src/ComponentLifecycle.ts new file mode 100644 index 000000000..6390ce028 --- /dev/null +++ b/packages/doenetml-worker-javascript/src/ComponentLifecycle.ts @@ -0,0 +1,264 @@ +import { postProcessCopy } from "./utils/copy"; +import { createNewComponentIndices } from "./utils/componentIndices"; + +/** + * Bookkeeping for component objects in the live tree: + * + * - `registerComponent` / `deregisterComponent` add and remove components + * from `core._components` (the canonical registry). + * - `setAncestors` walks the tree to keep each component's `ancestors` + * chain in sync with its parent. + * - `spliceChildren`, `addChildrenAndRecurseToShadows`, + * `processNewDefiningChildren` orchestrate insertion of new defining + * children into a parent and propagation to any composites that + * shadow that parent. + * + * Stateless — holds a back-reference to Core to read `_components`, + * `parameterStack`, and to invoke `deriveChildResultsFromDefiningChildren` + * and `createIsolatedComponents` (the latter still on Core through Phase 3). + */ +export class ComponentLifecycle { + core: any; + + constructor({ core }: { core: any }) { + this.core = core; + } + + registerComponent(component: any): void { + if (this.core._components[component.componentIdx] !== undefined) { + throw Error(`Duplicate component index: ${component.componentIdx}`); + } + this.core._components[component.componentIdx] = component; + } + + deregisterComponent(component: any, recursive: boolean = true): void { + if (recursive === true) { + for (let childIdxStr in component.allChildren) { + this.deregisterComponent( + component.allChildren[childIdxStr].component, + ); + } + } + + delete this.core._components[component.componentIdx]; + } + + setAncestors(component: any, ancestors: any[] = []): void { + // set ancestors based on allChildren and attribute components + // so that all components get ancestors + // even if not activeChildren or definingChildren + + component.ancestors = ancestors; + + let ancestorsForChildren = [ + { + componentIdx: component.componentIdx, + componentClass: component.constructor, + }, + ...component.ancestors, + ]; + + for (const childIdxStr in component.allChildren) { + let unproxiedChild = this.core._components[childIdxStr]; + // Note: when add and deleting replacements of shadowed composites, + // it is possible that we end up processing the defining children of ancestors of the composite + // while we were delaying processing the defining children of the composite's parent, + // which could lead to an attempt to set the ancestors of that composite's parent's children + // while the booking variable allChildren is in an inconsistent state. + // To avoid an error, we don't set ancestors in the case + // (See test "references to internal and external components, inconsistent new namespaces" + // from conditionalcontent.cy.js for an example that triggered the need for this check.) + // TODO: can we avoid the need for this check by preventing the algorithm from + // attempting to set ancestors in this case? + if (unproxiedChild) { + this.setAncestors(unproxiedChild, ancestorsForChildren); + } + } + + for (let attrName in component.attributes) { + let comp = component.attributes[attrName].component; + if (comp) { + this.setAncestors(comp, ancestorsForChildren); + } + } + } + + async addChildrenAndRecurseToShadows({ + parent, + indexOfDefiningChildren, + newChildren, + }: { + parent: any; + indexOfDefiningChildren: number; + newChildren: any[]; + }): Promise { + this.spliceChildren(parent, indexOfDefiningChildren, newChildren); + + let newChildrenResult = await this.processNewDefiningChildren({ + parent, + expandComposites: true, + }); + + let addedComponents: Record = {}; + let deletedComponents: Record = {}; + + if (!newChildrenResult.success) { + // try again, this time force expanding composites before giving up + newChildrenResult = await this.processNewDefiningChildren({ + parent, + expandComposites: true, + forceExpandComposites: true, + }); + + if (!newChildrenResult.success) { + return newChildrenResult; + } + } + + for (let child of newChildren) { + if (typeof child === "object") { + addedComponents[child.componentIdx] = child; + } + } + + if (parent.shadowedBy) { + for (let shadowingParent of parent.shadowedBy) { + if ( + shadowingParent.shadows.propVariable || + shadowingParent.constructor.doNotExpandAsShadowed + ) { + continue; + } + + let shadowingSerializeChildren: any[] = []; + let nComponents = this.core._components.length; + + for (let child of newChildren) { + if (typeof child === "object") { + const serializedComponent = await child.serialize(); + + const res = createNewComponentIndices( + [serializedComponent], + nComponents, + ); + nComponents = res.nComponents; + + shadowingSerializeChildren.push(...res.components); + } else { + shadowingSerializeChildren.push(child); + } + } + + if (nComponents > this.core.components.length) { + this.core._components[nComponents - 1] = undefined; + } + + shadowingSerializeChildren = postProcessCopy({ + serializedComponents: shadowingSerializeChildren, + componentIdx: shadowingParent.shadows.compositeIdx, + }); + + let unproxiedShadowingParent = + this.core._components[shadowingParent.componentIdx]; + this.core.parameterStack.push( + unproxiedShadowingParent.sharedParameters, + false, + ); + + let createResult = await this.core.createIsolatedComponents({ + serializedComponents: shadowingSerializeChildren, + ancestors: shadowingParent.ancestors, + }); + + this.core.parameterStack.pop(); + + let shadowResult = await this.addChildrenAndRecurseToShadows({ + parent: unproxiedShadowingParent, + indexOfDefiningChildren, + newChildren: createResult.components, + }); + + if (!shadowResult.success) { + throw Error( + `was able to add components to parent but not shadows!`, + ); + } + + Object.assign(addedComponents, shadowResult.addedComponents); + } + } + + return { + success: true, + deletedComponents, + addedComponents, + }; + } + + async processNewDefiningChildren({ + parent, + expandComposites = true, + forceExpandComposites = false, + }: { + parent: any; + expandComposites?: boolean; + forceExpandComposites?: boolean; + }): Promise { + this.core.parameterStack.push(parent.sharedParameters, false); + let childResult = + await this.core.deriveChildResultsFromDefiningChildren({ + parent, + expandComposites, + forceExpandComposites, + }); + this.core.parameterStack.pop(); + + let ancestorsForChildren = [ + { + componentIdx: parent.componentIdx, + componentClass: parent.constructor, + }, + ...parent.ancestors, + ]; + + // set ancestors for allChildren of parent + // since could replace newChildren by adapters or via composites + for (const childIdxStr in parent.allChildren) { + let unproxiedChild = this.core._components[childIdxStr]; + this.setAncestors(unproxiedChild, ancestorsForChildren); + } + + return childResult; + } + + spliceChildren( + parent: any, + indexOfDefiningChildren: number, + newChildren: any[], + ): void { + // splice newChildren into parent.definingChildren + // definingChildrenNumber is the index of parent.definingChildren + // before which to splice the newChildren (set to array length to add at end) + + let numDefiningChildren = parent.definingChildren.length; + + if ( + !Number.isInteger(indexOfDefiningChildren) || + indexOfDefiningChildren > numDefiningChildren || + indexOfDefiningChildren < 0 + ) { + throw Error( + "Can't add children at index " + + indexOfDefiningChildren + + ". Invalid index.", + ); + } + + // perform the actual splicing into children + parent.definingChildren.splice( + indexOfDefiningChildren, + 0, + ...newChildren, + ); + } +} diff --git a/packages/doenetml-worker-javascript/src/Core.js b/packages/doenetml-worker-javascript/src/Core.js index 68be61537..2976c4b72 100644 --- a/packages/doenetml-worker-javascript/src/Core.js +++ b/packages/doenetml-worker-javascript/src/Core.js @@ -27,9 +27,15 @@ import { unwrapSource, } from "./utils/dast/convertNormalizedDast"; import { DependencyHandler } from "./Dependencies"; +import { ActionTriggerScheduler } from "./ActionTriggerScheduler"; import { AutoSubmitManager } from "./AutoSubmitManager"; +import { ChildMatcher } from "./ChildMatcher"; +import { ComponentLifecycle } from "./ComponentLifecycle"; +import { DeletionEngine } from "./DeletionEngine"; import { DiagnosticsManager } from "./DiagnosticsManager"; import { NavigationHandler } from "./NavigationHandler"; +import { ProcessQueue } from "./ProcessQueue"; +import { RendererInstructionBuilder } from "./RendererInstructionBuilder"; import { ResolverAdapter } from "./ResolverAdapter"; import { StatePersistence } from "./StatePersistence"; import { VisibilityTracker } from "./VisibilityTracker"; @@ -196,6 +202,16 @@ export default class Core { this.autoSubmitManager = new AutoSubmitManager({ core: this }); this.navigationHandler = new NavigationHandler({ core: this }); this.resolverAdapter = new ResolverAdapter({ core: this }); + this.rendererInstructionBuilder = new RendererInstructionBuilder({ + core: this, + }); + this.processQueueManager = new ProcessQueue({ core: this }); + this.componentLifecycle = new ComponentLifecycle({ core: this }); + this.childMatcher = new ChildMatcher({ core: this }); + this.deletionEngine = new DeletionEngine({ core: this }); + this.actionTriggerScheduler = new ActionTriggerScheduler({ + core: this, + }); // console.time('serialize doenetML'); @@ -236,20 +252,20 @@ export default class Core { this._components = []; this.componentIdxByStateId = {}; this._components[this.nComponentsInit - 1] = undefined; - this.componentsToRender = {}; - this.componentsWithChangedChildrenToRender = new Set([]); this.errorComponentsToAdd = []; - this.stateVariableChangeTriggers = {}; - this.actionsChangedToActions = {}; - this.originsOfActionsChangedToActions = {}; + // Reset action-trigger registries managed by `this.actionTriggerScheduler` + // (see ActionTriggerScheduler.ts) so a previous run does not leak in. + this.actionTriggerScheduler.reset(); this.essentialValuesSavedInDefinition = {}; this.statePersistence = new StatePersistence({ core: this }); - // rendererState the current state of each renderer, keyed by componentIdx - this.rendererState = {}; + // Reset renderer state managed by `this.rendererInstructionBuilder` + // (see RendererInstructionBuilder.ts) so a previous run does not leak + // into this document. + this.rendererInstructionBuilder.reset(); // rendererVariablesByComponentType is a description // of the which variables are sent to the renderers, @@ -280,9 +296,12 @@ export default class Core { ); } - this.processQueue = []; - - this.stopProcessingRequests = false; + // Reset the process queue managed by `this.processQueueManager` + // (see ProcessQueue.ts) so a previous run does not leak into this + // document. + this.processQueueManager.queue = []; + this.processQueueManager.processing = false; + this.processQueueManager.stopProcessingRequests = false; this.dependencies = new DependencyHandler({ _components: this._components, @@ -450,13 +469,26 @@ export default class Core { ); } - callUpdateRenderers(args, init = false) { - let diagnostics = undefined; - if (this.hasPendingDiagnostics) { - diagnostics = this.getDiagnostics().diagnostics; - } + // Renderer instruction building lives in `this.rendererInstructionBuilder` + // (see RendererInstructionBuilder.ts). The accessors and methods below + // preserve the public surface (`core.componentsToRender`, + // `core.rendererState`, `core.callUpdateRenderers`, etc.) by delegating. + + get componentsToRender() { + return this.rendererInstructionBuilder.componentsToRender; + } + + get componentsWithChangedChildrenToRender() { + return this.rendererInstructionBuilder + .componentsWithChangedChildrenToRender; + } + + get rendererState() { + return this.rendererInstructionBuilder.rendererState; + } - this.updateRenderersCallback({ ...args, init, diagnostics }); + callUpdateRenderers(args, init = false) { + return this.rendererInstructionBuilder.callUpdateRenderers(args, init); } // Diagnostic state and helpers live in `this.diagnosticsManager` @@ -718,463 +750,52 @@ export default class Core { return newComponents; } - async updateRendererInstructions({ - componentNamesToUpdate, - sourceOfUpdate = {}, - actionId, - }) { - let deletedRenderers = []; - - let updateInstructions = []; - let rendererStatesToUpdate = []; - - let newChildrenInstructions = {}; - - // copy components with changed children and reset for next time - let componentsWithChangedChildrenToRenderInProgress = - this.componentsWithChangedChildrenToRender; - this.componentsWithChangedChildrenToRender = new Set([]); - - //TODO: Figure out what we need from here - for (let componentIdx of componentsWithChangedChildrenToRenderInProgress) { - if (componentIdx in this.componentsToRender) { - // check to see if current children who render are - // different from last time rendered - - let currentChildIdentifiers = []; - let unproxiedComponent = this._components[componentIdx]; - let indicesToRender = []; - - if ( - unproxiedComponent && - unproxiedComponent.constructor.renderChildren - ) { - if (!unproxiedComponent.matchedCompositeChildren) { - await this.deriveChildResultsFromDefiningChildren({ - parent: unproxiedComponent, - expandComposites: true, - forceExpandComposites: true, - }); - } - - indicesToRender = - await this.returnActiveChildrenIndicesToRender( - unproxiedComponent, - ); - - let renderedInd = 0; - for (let [ - ind, - child, - ] of unproxiedComponent.activeChildren.entries()) { - if (indicesToRender.includes(ind)) { - if (child.rendererType) { - currentChildIdentifiers.push( - `nameType:${child.componentIdx};${child.componentType}`, - ); - renderedInd++; - } else if (typeof child === "string") { - currentChildIdentifiers.push( - `string${renderedInd}:${child}`, - ); - renderedInd++; - } else if (typeof child === "number") { - currentChildIdentifiers.push( - `number${renderedInd}:${child.toString()}`, - ); - renderedInd++; - } else { - currentChildIdentifiers.push(""); - } - } else { - currentChildIdentifiers.push(""); - } - } - } - - let previousChildRenderers = - this.componentsToRender[componentIdx].children; - - let previousChildIdentifiers = []; - for (let [ind, child] of previousChildRenderers.entries()) { - if (child === null) { - previousChildIdentifiers.push(""); - } else if (child.componentIdx != undefined) { - previousChildIdentifiers.push( - `nameType:${child.componentIdx};${child.componentType}`, - ); - } else if (typeof child === "string") { - previousChildIdentifiers.push(`string${ind}:${child}`); - } else if (typeof child === "number") { - previousChildIdentifiers.push( - `number${ind}:${child.toString()}`, - ); - } - } - - if ( - currentChildIdentifiers.length !== - previousChildIdentifiers.length || - currentChildIdentifiers.some( - (v, i) => v !== previousChildIdentifiers[i], - ) - ) { - // delete old renderers - for (let child of previousChildRenderers) { - if (child?.componentIdx != undefined) { - let deletedNames = - this.deleteFromComponentsToRender({ - componentIdx: child.componentIdx, - recurseToChildren: true, - componentsWithChangedChildrenToRenderInProgress, - }); - deletedRenderers.push(...deletedNames); - } - } - - // create new renderers - let childrenToRender = []; - if (indicesToRender.length > 0) { - for (let [ - ind, - child, - ] of unproxiedComponent.activeChildren.entries()) { - if (indicesToRender.includes(ind)) { - if (child.rendererType) { - let results = - await this.initializeRenderedComponentInstruction( - child, - componentsWithChangedChildrenToRenderInProgress, - ); - childrenToRender.push( - results.componentToRender, - ); - rendererStatesToUpdate.push( - ...results.rendererStatesToUpdate, - ); - } else if (typeof child === "string") { - childrenToRender.push(child); - } else if (typeof child === "number") { - childrenToRender.push(child.toString()); - } else { - childrenToRender.push(null); - } - } else { - childrenToRender.push(null); - } - } - } - - this.componentsToRender[componentIdx].children = - childrenToRender; - - newChildrenInstructions[componentIdx] = childrenToRender; - - componentsWithChangedChildrenToRenderInProgress.delete( - componentIdx, - ); - - if (!componentNamesToUpdate.includes(componentIdx)) { - componentNamesToUpdate.push(componentIdx); - } - } - } - } - - for (let componentIdx of componentNamesToUpdate) { - if ( - componentIdx in this.componentsToRender - // && !deletedRenderers.includes(componentIdx) TODO: what if recreate with same name? - ) { - let component = this._components[componentIdx]; - if (component) { - let stateValuesForRenderer = {}; - for (let stateVariable in component.state) { - if (component.state[stateVariable].forRenderer) { - let value = removeFunctionsMathExpressionClass( - await component.state[stateVariable].value, - ); - // if (value !== null && typeof value === 'object') { - // value = new Proxy(value, readOnlyProxyHandler) - // } - stateValuesForRenderer[stateVariable] = value; - } - } - - if (component.compositeReplacementActiveRange) { - stateValuesForRenderer._compositeReplacementActiveRange = - component.compositeReplacementActiveRange; - } - - let newRendererState = { - componentIdx, - stateValues: stateValuesForRenderer, - rendererType: component.rendererType, // TODO: need this to ignore baseVariables change: is this right place? - }; - - // this.renderState is used to save the renderer state to the database - if (!this.rendererState[componentIdx]) { - this.rendererState[componentIdx] = {}; - } - - this.rendererState[componentIdx].stateValues = - stateValuesForRenderer; - - // only add childrenInstructions if they changed - if (newChildrenInstructions[componentIdx]) { - newRendererState.childrenInstructions = - newChildrenInstructions[componentIdx]; - this.rendererState[componentIdx].childrenInstructions = - newChildrenInstructions[componentIdx]; - } - - rendererStatesToUpdate.push(newRendererState); - } - } - } - - // rendererStatesToUpdate = rendererStatesToUpdate.filter(x => !deletedRenderers.includes(x)) - if (rendererStatesToUpdate.length > 0) { - let instruction = { - instructionType: "updateRendererStates", - rendererStatesToUpdate, - sourceOfUpdate, - }; - updateInstructions.splice(0, 0, instruction); - } - - this.callUpdateRenderers({ updateInstructions, actionId }); + async updateRendererInstructions(args) { + return this.rendererInstructionBuilder.updateRendererInstructions(args); } async initializeRenderedComponentInstruction( component, - componentsWithChangedChildrenToRenderInProgress = new Set([]), + componentsWithChangedChildrenToRenderInProgress, ) { - if (component.rendererType === undefined) { - return; - } - - if (!component.matchedCompositeChildren) { - await this.deriveChildResultsFromDefiningChildren({ - parent: component, - expandComposites: true, //forceExpandComposites: true, - }); - } - - let rendererStatesToUpdate = []; - let rendererStatesToForceUpdate = []; - - let stateValuesForRenderer = {}; - let stateValuesForRendererAlwaysUpdate = {}; - let alwaysUpdate = false; - for (let stateVariable in component.state) { - if (component.state[stateVariable].forRenderer) { - stateValuesForRenderer[stateVariable] = - removeFunctionsMathExpressionClass( - await component.state[stateVariable].value, - ); - if (component.state[stateVariable].alwaysUpdateRenderer) { - alwaysUpdate = true; - } - } - } - - if (component.compositeReplacementActiveRange) { - stateValuesForRenderer._compositeReplacementActiveRange = - component.compositeReplacementActiveRange; - } - - if (alwaysUpdate) { - stateValuesForRendererAlwaysUpdate = stateValuesForRenderer; - } - - let componentIdx = component.componentIdx; - - let childrenToRender = []; - if (component.constructor.renderChildren) { - let indicesToRender = - await this.returnActiveChildrenIndicesToRender(component); - for (let [ind, child] of component.activeChildren.entries()) { - if (indicesToRender.includes(ind)) { - if (child.rendererType) { - let results = - await this.initializeRenderedComponentInstruction( - child, - componentsWithChangedChildrenToRenderInProgress, - ); - childrenToRender.push(results.componentToRender); - rendererStatesToUpdate.push( - ...results.rendererStatesToUpdate, - ); - rendererStatesToForceUpdate.push( - ...results.rendererStatesToForceUpdate, - ); - } else if (typeof child === "string") { - childrenToRender.push(child); - } else if (typeof child === "number") { - childrenToRender.push(child.toString()); - } else { - childrenToRender.push(null); - } - } else { - childrenToRender.push(null); - } - } - } - - rendererStatesToUpdate.push({ - componentIdx, - stateValues: stateValuesForRenderer, - childrenInstructions: childrenToRender, - }); - if (Object.keys(stateValuesForRendererAlwaysUpdate).length > 0) { - rendererStatesToForceUpdate.push({ - componentIdx, - stateValues: stateValuesForRendererAlwaysUpdate, - }); - } - - // this.renderState is used to save the renderer state to the database - this.rendererState[componentIdx] = { - stateValues: stateValuesForRenderer, - childrenInstructions: childrenToRender, - }; - - componentsWithChangedChildrenToRenderInProgress.delete(componentIdx); - - let requestActions = {}; - for (let actionName in component.actions) { - requestActions[actionName] = { - actionName, - componentIdx: component.componentIdx, - }; - } - - for (let actionName in component.externalActions) { - let action = await component.externalActions[actionName]; - if (action) { - requestActions[actionName] = { - actionName, - componentIdx: action.componentIdx, - }; - } - } - - let rendererInstructions = { - componentIdx: componentIdx, - effectiveIdx: component.componentOrAdaptedIdx, - id: this.getRendererId(component), - componentType: component.componentType, - rendererType: component.rendererType, - actions: requestActions, - }; - - this.componentsToRender[componentIdx] = { - children: childrenToRender, - }; - - return { - componentToRender: rendererInstructions, - rendererStatesToUpdate, - rendererStatesToForceUpdate, - }; + return this.rendererInstructionBuilder.initializeRenderedComponentInstruction( + component, + componentsWithChangedChildrenToRenderInProgress, + ); } - /** - * Get the `rendererId` of `component`, - * where `rendererId` is the `rootName` of the component, if it exists, - * else the `componentIdx` as a string. - * - * The `rootName` is the simplest unique reference to the component - * when the document root is the origin. As `rootName` is designed to be - * a HTML id, indices are represented with `:`. For example, - * if `$a.b[2][3].c` is the simplest reference to a component from the root, - * then its root name will be `a.b:2:3.c`. - * - * If a component was adapted from another component, - * then the `renderedId` of the original component is used instead, - * as that corresponds to the component that was authored. - */ getRendererId(component) { - return ( - this.rootNames?.[component.componentOrAdaptedIdx] ?? - `_id_${component.componentOrAdaptedIdx.toString()}` + return this.rendererInstructionBuilder.getRendererId(component); + } + + deleteFromComponentsToRender(args) { + return this.rendererInstructionBuilder.deleteFromComponentsToRender( + args, ); } - deleteFromComponentsToRender({ - componentIdx, - recurseToChildren = true, - componentsWithChangedChildrenToRenderInProgress, - }) { - let deletedComponentNames = [componentIdx]; - if (recurseToChildren) { - let componentInstruction = this.componentsToRender[componentIdx]; - if (componentInstruction) { - for (let child of componentInstruction.children) { - if (child) { - let additionalDeleted = - this.deleteFromComponentsToRender({ - componentIdx: child.componentIdx, - recurseToChildren, - componentsWithChangedChildrenToRenderInProgress, - }); - deletedComponentNames.push(...additionalDeleted); - } - } - } - } - delete this.componentsToRender[componentIdx]; - componentsWithChangedChildrenToRenderInProgress.delete(componentIdx); + // Action-trigger scheduling lives in `this.actionTriggerScheduler` + // (see ActionTriggerScheduler.ts). The accessors and methods below + // preserve the public surface (`core.stateVariableChangeTriggers`, + // `core.actionsChangedToActions`, `core.originsOfActionsChangedToActions`, + // and the four scheduling methods) by delegating through. - return deletedComponentNames; + get stateVariableChangeTriggers() { + return this.actionTriggerScheduler.stateVariableChangeTriggers; } - async processStateVariableTriggers(updateRenderersIfTriggered = false) { - // TODO: can we make this more efficient by only checking components that changed? - // componentsToUpdateRenderers is close, but it includes only rendered components - // and we could have components with triggers that are not rendered - - let triggeredAction = false; - - for (const componentIdxStr in this.stateVariableChangeTriggers) { - const componentIdx = Number(componentIdxStr); - let component = this._components[componentIdx]; - for (let stateVariable in this.stateVariableChangeTriggers[ - componentIdx - ]) { - let triggerInstructions = - this.stateVariableChangeTriggers[componentIdx][ - stateVariable - ]; - - let value = await component.state[stateVariable].value; + get actionsChangedToActions() { + return this.actionTriggerScheduler.actionsChangedToActions; + } - if (value !== triggerInstructions.previousValue) { - let previousValue = triggerInstructions.previousValue; - triggerInstructions.previousValue = value; - let action = component.actions[triggerInstructions.action]; - if (action) { - await this.performAction({ - componentIdx, - actionName: triggerInstructions.action, - args: { - stateValues: { [stateVariable]: value }, - previousValues: { - [stateVariable]: previousValue, - }, - skipRendererUpdate: true, - }, - }); - triggeredAction = true; - } - } - } - } + get originsOfActionsChangedToActions() { + return this.actionTriggerScheduler.originsOfActionsChangedToActions; + } - if (triggeredAction && updateRenderersIfTriggered) { - await this.updateAllChangedRenderers(); - } + async processStateVariableTriggers(updateRenderersIfTriggered = false) { + return this.actionTriggerScheduler.processStateVariableTriggers( + updateRenderersIfTriggered, + ); } async expandAllComposites(component, force = false) { @@ -1915,169 +1536,17 @@ export default class Core { } recordStateVariablesMustEvaluate(componentIdx) { - let comp = this._components[componentIdx]; - - for (let vName in comp.state) { - if (comp.state[vName].mustEvaluate) { - this.updateInfo.stateVariablesToEvaluate.push({ - componentIdx, - stateVariable: vName, - }); - } - } - } - - async deriveChildResultsFromDefiningChildren({ - parent, - expandComposites = true, - forceExpandComposites = false, - }) { - // console.log(`derive child results for ${parent.componentIdx}, ${expandComposites}, ${forceExpandComposites}`) - - if (!this.derivingChildResults) { - this.derivingChildResults = []; - } - if (this.derivingChildResults.includes(parent.componentIdx)) { - // console.log(`not deriving child results of ${parent.componentIdx} while in the middle of deriving them already`) - return { success: false, skipping: true }; - } - this.derivingChildResults.push(parent.componentIdx); - - // create allChildren and activeChildren from defining children - // apply child logic and substitute adapters to modify activeChildren - - // if (parent.activeChildren) { - // // if there are any deferred child state variables - // // evaluate them before changing the active children - // this.evaluatedDeferredChildStateVariables(parent); - // } - - // attempt to expand composites before modifying active children - let result = await this.expandCompositeOfDefiningChildren( - parent, - parent.definingChildren, - expandComposites, - forceExpandComposites, + return this.actionTriggerScheduler.recordStateVariablesMustEvaluate( + componentIdx, ); - parent.unexpandedCompositesReady = result.unexpandedCompositesReady; - parent.unexpandedCompositesNotReady = - result.unexpandedCompositesNotReady; - - let previousActiveChildren; - - if (parent.activeChildren) { - previousActiveChildren = parent.activeChildren.map((child) => - child.componentIdx ? child.componentIdx : child, - ); - } - - parent.activeChildren = parent.definingChildren.slice(); // shallow copy - - // allChildren include activeChildren, definingChildren, - // and possibly some children that are neither - // (which could occur when a composite is expanded and the result is adapted) - // ignores string and number primitive children - parent.allChildren = {}; - - // allChildrenOrdered contains same children as allChildren, - // but retaining an order that we can use for counters. - // If defining children are replaced my composite replacements or adapters, - // those children will come immediately after the corresponding defining child - parent.allChildrenOrdered = []; - - for (let ind = 0; ind < parent.activeChildren.length; ind++) { - let child = parent.activeChildren[ind]; - let childIdx; - if (typeof child !== "object") { - continue; - } - - childIdx = child.componentIdx; - - parent.allChildren[childIdx] = { - activeChildrenIndex: ind, - definingChildrenIndex: ind, - component: child, - }; - - parent.allChildrenOrdered.push(childIdx); - } - - // if any of activeChildren are expanded compositeComponents - // replace with new components given by the composite component - await this.replaceCompositeChildren(parent); - - let childGroupResults = await this.matchChildrenToChildGroups(parent); - - if (childGroupResults.success) { - delete this.unmatchedChildren[parent.componentIdx]; - parent.childrenMatchedWithPlaceholders = true; - parent.matchedCompositeChildrenWithPlaceholders = true; - } else { - parent.childrenMatchedWithPlaceholders = false; - parent.matchedCompositeChildrenWithPlaceholders = true; - - let unmatchedChildrenTypes = []; - for (let child of childGroupResults.unmatchedChildren) { - if (typeof child === "string") { - unmatchedChildrenTypes.push("string"); - } else { - unmatchedChildrenTypes.push( - "`<" + child.componentType + ">`", - ); - if ( - this.componentInfoObjects.isInheritedComponentType({ - inheritedComponentType: child.componentType, - baseComponentType: "_composite", - }) - ) { - parent.matchedCompositeChildrenWithPlaceholders = false; - } - } - } - - if (parent.doenetAttributes.isAttributeChildFor) { - let attributeForComponentType = - parent.ancestors[0].componentClass.componentType; - this.unmatchedChildren[parent.componentIdx] = { - message: `Invalid format for attribute ${parent.doenetAttributes.isAttributeChildFor} of \`<${attributeForComponentType}>\`.`, - }; - } else { - this.unmatchedChildren[parent.componentIdx] = { - message: `Invalid children for \`<${ - parent.componentType - }>\`: Found invalid children: ${unmatchedChildrenTypes.join( - ", ", - )}`, - }; - } - } - - await this.dependencies.addBlockersFromChangedActiveChildren({ - parent, - }); - - let ind = this.derivingChildResults.indexOf(parent.componentIdx); - - this.derivingChildResults.splice(ind, 1); + } - if (parent.constructor.renderChildren) { - let childrenUnchanged = - previousActiveChildren && - previousActiveChildren.length == parent.activeChildren.length && - parent.activeChildren.every((child, ind) => - child.componentIdx - ? child.componentIdx === previousActiveChildren[ind] - : child === previousActiveChildren[ind], - ); - if (!childrenUnchanged) { - this.componentsWithChangedChildrenToRender.add( - parent.componentIdx, - ); - } - } + // Child matching, adapter substitution, and rendered-child filtering + // live in `this.childMatcher` (see ChildMatcher.ts). The methods below + // preserve the public surface by delegating through. - return childGroupResults; + async deriveChildResultsFromDefiningChildren(args) { + return this.childMatcher.deriveChildResultsFromDefiningChildren(args); } async expandCompositeOfDefiningChildren( @@ -2181,287 +1650,27 @@ export default class Core { } async matchChildrenToChildGroups(parent) { - parent.childMatchesByGroup = {}; - - for (let groupName in parent.constructor.childGroupIndsByName) { - parent.childMatchesByGroup[groupName] = []; - } - - let success = true; - - let unmatchedChildren = []; - - for (let [ind, child] of parent.activeChildren.entries()) { - let childType = - typeof child !== "object" ? typeof child : child.componentType; - - if (childType === undefined) { - success = false; - unmatchedChildren.push(child); - continue; - } - - let result = this.findChildGroup(childType, parent.constructor); - - if (result.success) { - parent.childMatchesByGroup[result.group].push(ind); - - if (result.adapterIndUsed !== undefined) { - await this.substituteAdapter({ - parent, - childInd: ind, - adapterIndUsed: result.adapterIndUsed, - }); - } - } else { - success = false; - unmatchedChildren.push(child); - } - } - - return { success, unmatchedChildren }; + return this.childMatcher.matchChildrenToChildGroups(parent); } findChildGroup(childType, parentClass) { - let result = this.findChildGroupNoAdapters(childType, parentClass); - - if (result.success) { - return result; - } else if (childType === "string") { - return { success: false }; - } - - // check if can match with adapters - let childClass = - this.componentInfoObjects.allComponentClasses[childType]; - - // if didn't match child, attempt to match with child's adapters - let numAdapters = childClass.numAdapters; - - for (let n = 0; n < numAdapters; n++) { - let adapterComponentType = childClass.getAdapterComponentType( - n, - this.componentInfoObjects.publicStateVariableInfo, - ); - - result = this.findChildGroupNoAdapters( - adapterComponentType, - parentClass, - ); - - if (result.success) { - result.adapterIndUsed = n; - return result; - } - } - - // lastly try to match with afterAdapters set to true - return this.findChildGroupNoAdapters(childType, parentClass, true); + return this.childMatcher.findChildGroup(childType, parentClass); } - findChildGroupNoAdapters( - componentType, - parentClass, - afterAdapters = false, - ) { - if (parentClass.childGroupOfComponentType[componentType]) { - return { - success: true, - group: parentClass.childGroupOfComponentType[componentType], - }; - } - - for (let group of parentClass.childGroups) { - for (let typeFromGroup of group.componentTypes) { - if ( - this.componentInfoObjects.isInheritedComponentType({ - inheritedComponentType: componentType, - baseComponentType: typeFromGroup, - }) - ) { - if (group.matchAfterAdapters && !afterAdapters) { - continue; - } - // don't match composites to the base component - // so that they will expand - if ( - !( - typeFromGroup === "_base" && - this.componentInfoObjects.isInheritedComponentType({ - inheritedComponentType: componentType, - baseComponentType: "_composite", - }) - ) - ) { - parentClass.childGroupOfComponentType[componentType] = - group.group; - - return { - success: true, - group: group.group, - }; - } - } - } - } - - return { success: false }; + findChildGroupNoAdapters(componentType, parentClass, afterAdapters = false) { + return this.childMatcher.findChildGroupNoAdapters( + componentType, + parentClass, + afterAdapters, + ); } async returnActiveChildrenIndicesToRender(component) { - let indicesToRender = []; - let numChildrenToRender = Infinity; - if ("numChildrenToRender" in component.state) { - numChildrenToRender = - await component.stateValues.numChildrenToRender; - } - let childIndicesToRender = null; - if ("childIndicesToRender" in component.state) { - childIndicesToRender = - await component.stateValues.childIndicesToRender; - } - - for (let [ind, child] of component.activeChildren.entries()) { - if (ind >= numChildrenToRender) { - break; - } - - if (childIndicesToRender && !childIndicesToRender.includes(ind)) { - continue; - } - - if (typeof child === "object") { - if ( - child.constructor.sendToRendererEvenIfHidden || - !(await child.stateValues.hidden) - ) { - indicesToRender.push(ind); - } - } else { - // if have a primitive, - // will be hidden if a composite source is hidden - let hidden = false; - if (component.compositeReplacementActiveRange) { - for (let compositeInfo of component.compositeReplacementActiveRange) { - let composite = - this._components[compositeInfo.compositeIdx]; - if (await composite.stateValues.hidden) { - if ( - compositeInfo.firstInd <= ind && - compositeInfo.lastInd >= ind - ) { - hidden = true; - break; - } - } - } - } - if (!hidden) { - indicesToRender.push(ind); - } - } - } - - return indicesToRender; + return this.childMatcher.returnActiveChildrenIndicesToRender(component); } - async substituteAdapter({ parent, childInd, adapterIndUsed }) { - // replace activeChildren with their adapters - - let originalChild = parent.activeChildren[childInd]; - - let newSerializedChild; - if (originalChild.componentIdx != undefined) { - newSerializedChild = originalChild.getAdapter(adapterIndUsed); - newSerializedChild.componentIdx = this._components.length; - this._components[this._components.length] = undefined; - } else { - // XXX: how does this work with the new componentIdx approach? - - // child isn't a component, just an object with a componentType - // Create an object that is just the componentType of the adapter - newSerializedChild = { - componentType: this.componentInfoObjects.allComponentClasses[ - originalChild.componentType - ].getAdapterComponentType( - adapterIndUsed, - this.componentInfoObjects.publicStateVariableInfo, - ), - placeholderInd: originalChild.placeholderInd + "adapt", - }; - } - - let adapter = originalChild.adapterUsed; - - if ( - adapter === undefined || - adapter.componentType !== newSerializedChild.componentType - ) { - if (originalChild.componentIdx != undefined) { - newSerializedChild.adaptedFrom = originalChild.componentIdx; - assignDoenetMLRange( - [newSerializedChild], - originalChild.position, - originalChild.sourceDoc, - ); - let newChildrenResult = await this.createIsolatedComponents({ - serializedComponents: [newSerializedChild], - shadow: true, - ancestors: originalChild.ancestors, - }); - - adapter = newChildrenResult.components[0]; - } else { - // XXX: how does this work with the new componentIdx approach? - - // didn't have a component for the original child, just a componentType - // Adapter will also just be the componentType returned from childmatches - newSerializedChild.adaptedFrom = originalChild; - adapter = newSerializedChild; - } - } - - // Replace originalChild with its adapter in activeChildren - parent.activeChildren.splice(childInd, 1, adapter); - - // TODO: if originalChild is a placeholder, we lose track of it - // (other than through adaptedFrom of adapted) - // once we splice it out of activeChildren. Is that a problem? - - // Update allChildren to show that originalChild is no longer active - // and that adapter is now an active child - if (originalChild.componentIdx != undefined) { - // ignore placeholder active children - delete parent.allChildren[originalChild.componentIdx] - .activeChildrenIndex; - parent.allChildren[adapter.componentIdx] = { - activeChildrenIndex: childInd, - component: adapter, - }; - } - - // find index of originalChild in allChildrenOrdered - // and place adapter immediately afterward - if (originalChild.componentIdx != undefined) { - let originalInd = parent.allChildrenOrdered.indexOf( - originalChild.componentIdx, - ); - parent.allChildrenOrdered.splice( - originalInd + 1, - 0, - adapter.componentIdx, - ); - } else { - // adapter of placeholder - let originalInd = parent.allChildrenOrdered.indexOf( - originalChild.placeholderInd, - ); - parent.allChildrenOrdered.splice( - originalInd + 1, - 0, - adapter.placeholderInd, - ); - } + async substituteAdapter(args) { + return this.childMatcher.substituteAdapter(args); } async expandCompositeComponent(component) { @@ -5051,109 +4260,8 @@ export default class Core { } } - async checkForActionChaining({ component, stateVariables }) { - if (!component) { - return; - } - - if (!stateVariables) { - stateVariables = Object.keys(component.state); - } - - for (let varName of stateVariables) { - let stateVarObj = component.state[varName]; - - if (stateVarObj.chainActionOnActionOfStateVariableTargets) { - let chainInfo = - stateVarObj.chainActionOnActionOfStateVariableTargets; - let targetIds = await stateVarObj.value; - - let originObj = - this.originsOfActionsChangedToActions[ - component.componentIdx - ]; - - let previousIds; - if (originObj) { - previousIds = originObj[varName]; - } - - if (!previousIds) { - previousIds = []; - } - - let newTargets = []; - - if (Array.isArray(targetIds)) { - newTargets = [...targetIds]; - for (let id of newTargets) { - let indPrev = previousIds.indexOf(id); - - if (indPrev === -1) { - // found a component/action that wasn't previously chained - let componentActionsChained = - this.actionsChangedToActions[id]; - if (!componentActionsChained) { - componentActionsChained = - this.actionsChangedToActions[id] = []; - } - - componentActionsChained.push({ - componentIdx: component.componentIdx, - actionName: chainInfo.triggeredAction, - stateVariableDefiningChain: varName, - args: {}, - }); - } else { - // target was already chained - // remove from previous names to indicate it should still be chained - previousIds.splice(indPrev, 1); - } - } - } - - // if any ids are left in previousIds, - // then they should no longer be chained - for (let idToNoLongerChain of previousIds) { - let componentActionsChained = - this.actionsChangedToActions[idToNoLongerChain]; - if (componentActionsChained) { - let newComponentActionsChained = []; - - for (let chainedInfo of componentActionsChained) { - if ( - chainedInfo.componentIdx !== - component.componentIdx || - chainedInfo.stateVariableDefiningChain !== - varName - ) { - newComponentActionsChained.push(chainedInfo); - } - } - - this.actionsChangedToActions[idToNoLongerChain] = - newComponentActionsChained; - } - } - - if (newTargets.length > 0) { - if (!originObj) { - originObj = this.originsOfActionsChangedToActions[ - component.componentIdx - ] = {}; - } - originObj[varName] = newTargets; - } else if (originObj) { - delete originObj[varName]; - - if (Object.keys(originObj).length === 0) { - delete this.originsOfActionsChangedToActions[ - component.componentIdx - ]; - } - } - } - } + async checkForActionChaining(args) { + return this.actionTriggerScheduler.checkForActionChaining(args); } async initializeArrayEntryStateVariable({ @@ -8687,708 +7795,174 @@ export default class Core { // } // } + // Component-tree bookkeeping (registration, ancestors, defining-child + // splicing, propagation to shadows) lives in `this.componentLifecycle` + // (see ComponentLifecycle.ts). The methods below preserve the public + // surface by delegating through. + registerComponent(component) { - if (this._components[component.componentIdx] !== undefined) { - throw Error(`Duplicate component index: ${component.componentIdx}`); - } - this._components[component.componentIdx] = component; + return this.componentLifecycle.registerComponent(component); } deregisterComponent(component, recursive = true) { - if (recursive === true) { - for (let childIdxStr in component.allChildren) { - this.deregisterComponent( - component.allChildren[childIdxStr].component, - ); - } - } - - delete this._components[component.componentIdx]; + return this.componentLifecycle.deregisterComponent( + component, + recursive, + ); } setAncestors(component, ancestors = []) { - // set ancestors based on allChildren and attribute components - // so that all components get ancestors - // even if not activeChildren or definingChildren - - component.ancestors = ancestors; - - let ancestorsForChildren = [ - { - componentIdx: component.componentIdx, - componentClass: component.constructor, - }, - ...component.ancestors, - ]; - - for (const childIdxStr in component.allChildren) { - let unproxiedChild = this._components[childIdxStr]; - // Note: when add and deleting replacements of shadowed composites, - // it is possible that we end up processing the defining children of ancestors of the composite - // while we were delaying processing the defining children of the composite's parent, - // which could lead to an attempt to set the ancestors of that composite's parent's children - // while the booking variable allChildren is in an inconsistent state. - // To avoid an error, we don't set ancestors in the case - // (See test "references to internal and external components, inconsistent new namespaces" - // from conditionalcontent.cy.js for an example that triggered the need for this check.) - // TODO: can we avoid the need for this check by preventing the algorithm from - // attempting to set ancestors in this case? - if (unproxiedChild) { - this.setAncestors(unproxiedChild, ancestorsForChildren); - } - } - - for (let attrName in component.attributes) { - let comp = component.attributes[attrName].component; - if (comp) { - this.setAncestors(comp, ancestorsForChildren); - } - } + return this.componentLifecycle.setAncestors(component, ancestors); } - async addChildrenAndRecurseToShadows({ - parent, - indexOfDefiningChildren, - newChildren, - }) { - this.spliceChildren(parent, indexOfDefiningChildren, newChildren); - - let newChildrenResult = await this.processNewDefiningChildren({ - parent, - expandComposites: true, - }); - - let addedComponents = {}; - let deletedComponents = {}; - - if (!newChildrenResult.success) { - // try again, this time force expanding composites before giving up - newChildrenResult = await this.processNewDefiningChildren({ - parent, - expandComposites: true, - forceExpandComposites: true, - }); - - if (!newChildrenResult.success) { - return newChildrenResult; - } - } - - for (let child of newChildren) { - if (typeof child === "object") { - addedComponents[child.componentIdx] = child; - } - } - - if (parent.shadowedBy) { - for (let shadowingParent of parent.shadowedBy) { - if ( - shadowingParent.shadows.propVariable || - shadowingParent.constructor.doNotExpandAsShadowed - ) { - continue; - } - - let composite = - this._components[shadowingParent.shadows.compositeIdx]; - - let shadowingSerializeChildren = []; - let nComponents = this._components.length; - - for (let child of newChildren) { - if (typeof child === "object") { - const serializedComponent = await child.serialize(); - - const res = createNewComponentIndices( - [serializedComponent], - nComponents, - ); - nComponents = res.nComponents; - - shadowingSerializeChildren.push(...res.components); - } else { - shadowingSerializeChildren.push(child); - } - } - - if (nComponents > this.components.length) { - this._components[nComponents - 1] = undefined; - } - - shadowingSerializeChildren = postProcessCopy({ - serializedComponents: shadowingSerializeChildren, - componentIdx: shadowingParent.shadows.compositeIdx, - }); - - let unproxiedShadowingParent = - this._components[shadowingParent.componentIdx]; - this.parameterStack.push( - unproxiedShadowingParent.sharedParameters, - false, - ); - - let createResult = await this.createIsolatedComponents({ - serializedComponents: shadowingSerializeChildren, - ancestors: shadowingParent.ancestors, - }); - - this.parameterStack.pop(); - - let shadowResult = await this.addChildrenAndRecurseToShadows({ - parent: unproxiedShadowingParent, - indexOfDefiningChildren, - newChildren: createResult.components, - }); - - if (!shadowResult.success) { - throw Error( - `was able to add components to parent but not shadows!`, - ); - } - - Object.assign(addedComponents, shadowResult.addedComponents); - } - } - - return { - success: true, - deletedComponents, - addedComponents, - }; - } - - /** - * Create and insert `_error` siblings requested by state-variable definitions - * during initial document construction. - */ - async addQueuedErrorComponentsFromStateVariables() { - if (!this.errorComponentsToAdd?.length) { - return; - } - - const errorComponentsToAdd = this.errorComponentsToAdd; - this.errorComponentsToAdd = []; - - const numberInsertedAfterSource = {}; - - for (let errorInfo of errorComponentsToAdd) { - let sourceComponent = this._components[errorInfo.componentIdx]; - let parent; - - while (sourceComponent?.parentIdx > 0) { - const candidateParent = - this._components[sourceComponent.parentIdx]; - - if (!candidateParent) { - break; - } - - if (candidateParent.constructor.canDisplayChildErrors) { - parent = candidateParent; - break; - } - - sourceComponent = candidateParent; - } - - if (!parent) { - if (this.document?.constructor.canDisplayChildErrors) { - parent = this.document; - } - } - - if (!parent) { - continue; - } - - let indexOfDefiningChildren = parent.definingChildren.length; - - if (sourceComponent?.parentIdx === parent.componentIdx) { - const sourceInd = parent.definingChildren.findIndex( - (child) => - typeof child === "object" && - child.componentIdx === sourceComponent.componentIdx, - ); - - if (sourceInd !== -1) { - const numberAlreadyInserted = - numberInsertedAfterSource[ - sourceComponent.componentIdx - ] ?? 0; - indexOfDefiningChildren = - sourceInd + 1 + numberAlreadyInserted; - numberInsertedAfterSource[sourceComponent.componentIdx] = - numberAlreadyInserted + 1; - } - } - - let serializedErrorComponents = [ - { - type: "serialized", - componentType: "_error", - componentIdx: this._components.length, - state: { message: errorInfo.message }, - position: errorInfo.position, - sourceDoc: errorInfo.sourceDoc, - children: [], - attributes: {}, - doenetAttributes: {}, - }, - ]; - - this._components[this._components.length] = undefined; - - let ancestors = [ - { - componentIdx: parent.componentIdx, - componentClass: parent.constructor, - }, - ...parent.ancestors, - ]; - - this.parameterStack.push(parent.sharedParameters, false); - let createResult; - try { - createResult = await this.createIsolatedComponents({ - serializedComponents: serializedErrorComponents, - ancestors, - }); - } finally { - this.parameterStack.pop(); - } - - let addResults = await this.addChildrenAndRecurseToShadows({ - parent, - indexOfDefiningChildren, - newChildren: createResult.components, - }); - - if (!addResults.success) { - throw Error( - "Couldn't add error component from state variable evaluation.", - ); - } - } - } - - async processNewDefiningChildren({ - parent, - expandComposites = true, - forceExpandComposites = false, - }) { - this.parameterStack.push(parent.sharedParameters, false); - let childResult = await this.deriveChildResultsFromDefiningChildren({ - parent, - expandComposites, - forceExpandComposites, - }); - this.parameterStack.pop(); - - let ancestorsForChildren = [ - { - componentIdx: parent.componentIdx, - componentClass: parent.constructor, - }, - ...parent.ancestors, - ]; - - // set ancestors for allChildren of parent - // since could replace newChildren by adapters or via composites - for (const childIdxStr in parent.allChildren) { - let unproxiedChild = this._components[childIdxStr]; - this.setAncestors(unproxiedChild, ancestorsForChildren); - } - - return childResult; - } - - spliceChildren(parent, indexOfDefiningChildren, newChildren) { - // splice newChildren into parent.definingChildren - // definingChildrenNumber is the index of parent.definingChildren - // before which to splice the newChildren (set to array length to add at end) - - let numDefiningChildren = parent.definingChildren.length; - - if ( - !Number.isInteger(indexOfDefiningChildren) || - indexOfDefiningChildren > numDefiningChildren || - indexOfDefiningChildren < 0 - ) { - throw Error( - "Can't add children at index " + - indexOfDefiningChildren + - ". Invalid index.", - ); - } - - // perform the actual splicing into children - parent.definingChildren.splice( - indexOfDefiningChildren, - 0, - ...newChildren, - ); - } - - async deleteComponents({ - components, - deleteUpstreamDependencies = true, - skipProcessingChildrenOfParents = [], - }) { - // to delete a component, one must - // 1. recursively delete all children and attribute components - // 3. should we delete or mark components who are upstream dependencies? - // 4. for all other downstream dependencies, - // delete upstream link back to component - - if (!Array.isArray(components)) { - components = [components]; - } - - // TODO: if delete a shadow directly it should be an error - // (though it will be OK to delete them through other side effects) - - // step 1. Determine which components to delete - const componentsToDelete = {}; - this.determineComponentsToDelete({ - components, - deleteUpstreamDependencies, - componentsToDelete, - }); - - //Calculate parent set - const parentsOfPotentiallyDeleted = {}; - for (const componentIdxStr in componentsToDelete) { - const componentIdx = Number(componentIdxStr); - let component = componentsToDelete[componentIdx]; - let parent = this.components[component.parentIdx]; - - // only add parent if it is not in componentsToDelete itself - if ( - parent === undefined || - parent.componentIdx in componentsToDelete - ) { - continue; - } - let parentObj = parentsOfPotentiallyDeleted[component.parentIdx]; - if (parentObj === undefined) { - parentObj = { - parent: this._components[component.parentIdx], - childNamesToBeDeleted: new Set(), - }; - parentsOfPotentiallyDeleted[component.parentIdx] = parentObj; - } - parentObj.childNamesToBeDeleted.add(componentIdx); - } - - // if component is a replacement of another component, - // need to delete component from the replacement - // so that it isn't added back as a child of its parent - // Also keep track of which ones deleted so can add back to replacements - // if the deletion is unsuccessful - let replacementsDeletedFromComposites = []; - - for (const componentIdxStr in componentsToDelete) { - const componentIdx = Number(componentIdxStr); - let component = this._components[componentIdx]; - if (component.replacementOf) { - let composite = component.replacementOf; - - let replacementNames = composite.replacements.map( - (x) => x.componentIdx, - ); - - let replacementInd = replacementNames.indexOf(componentIdx); - if (replacementInd !== -1) { - composite.replacements.splice(replacementInd, 1); - if ( - !replacementsDeletedFromComposites.includes( - composite.componentIdx, - ) - ) { - replacementsDeletedFromComposites.push( - composite.componentIdx, - ); - } - } - } - } - - for (const compositeIdxStr of replacementsDeletedFromComposites) { - if (!(compositeIdxStr in componentsToDelete)) { - await this.dependencies.addBlockersFromChangedReplacements( - this._components[compositeIdxStr], - ); - } - } - - // delete component from parent's defining children - // and record parents - const allParents = []; - for (const parentIdxStr in parentsOfPotentiallyDeleted) { - const parentObj = parentsOfPotentiallyDeleted[parentIdxStr]; - const parent = parentObj.parent; - allParents.push(parent); - - // if (parent.activeChildren) { - // this.evaluatedDeferredChildStateVariables(parent); - // } - - for ( - let ind = parent.definingChildren.length - 1; - ind >= 0; - ind-- - ) { - const child = parent.definingChildren[ind]; - if (parentObj.childNamesToBeDeleted.has(child.componentIdx)) { - parent.definingChildren.splice(ind, 1); // delete from array - } - } - - if ( - !skipProcessingChildrenOfParents.includes(parent.componentIdx) - ) { - await this.processNewDefiningChildren({ - parent, - expandComposites: false, - }); - } - } - - for (const componentIdxStr in componentsToDelete) { - const componentIdx = Number(componentIdxStr); - const component = this._components[componentIdx]; - - if (component.shadows) { - const shadowedComponent = - this._components[component.shadows.componentIdx]; - if (shadowedComponent.shadowedBy.length === 1) { - delete shadowedComponent.shadowedBy; - } else { - shadowedComponent.shadowedBy.splice( - shadowedComponent.shadowedBy.indexOf(component), - 1, - ); - } - } - - this.dependencies.deleteAllDownstreamDependencies({ component }); - - // record any upstream dependencies that depend directly on componentIdx - // (componentIdentity, componentStateVariable*) - - for (let varName in this.dependencies.upstreamDependencies[ - component.componentIdx - ]) { - let upDeps = - this.dependencies.upstreamDependencies[ - component.componentIdx - ][varName]; - for (let upDep of upDeps) { - if ( - upDep.specifiedComponentName && - upDep.specifiedComponentName in componentsToDelete - ) { - let dependenciesMissingComponent = - this.dependencies.updateTriggers - .dependenciesMissingComponentBySpecifiedName[ - upDep.specifiedComponentName - ]; - if (!dependenciesMissingComponent) { - dependenciesMissingComponent = - this.dependencies.updateTriggers.dependenciesMissingComponentBySpecifiedName[ - upDep.specifiedComponentName - ] = []; - } - if (!dependenciesMissingComponent.includes(upDep)) { - dependenciesMissingComponent.push(upDep); - } - } - } - } - - await this.dependencies.deleteAllUpstreamDependencies({ - component, - }); - - if ( - !this.updateInfo.deletedStateVariables[component.componentIdx] - ) { - this.updateInfo.deletedStateVariables[component.componentIdx] = - []; - } - this.updateInfo.deletedStateVariables[component.componentIdx].push( - ...Object.keys(component.state), - ); - - this.updateInfo.deletedComponents[component.componentIdx] = true; - delete this.unmatchedChildren[component.componentIdx]; - - delete this.stateVariableChangeTriggers[component.componentIdx]; - } - - const componentsToRemoveFromResolver = []; - - for (const componentIdxStr in componentsToDelete) { - const componentIdx = Number(componentIdxStr); - let component = this._components[componentIdx]; - - if (component.replacementOf) { - const compositeSource = component.replacementOf; - - if ( - compositeSource.attributes.createComponentIdx?.primitive - ?.value == component.componentIdx - ) { - // If the component's index is being created from a composite, - // check if there is a source of that composite index that is not being deleted. - // In that case, we should not remove the component from the resolver. - let compositeCreatingComponentIdx = compositeSource; - - let foundUndeletedSourceOfComponentIdx = false; - while (true) { - if ( - !( - compositeCreatingComponentIdx.componentIdx in - componentsToDelete - ) - ) { - foundUndeletedSourceOfComponentIdx = true; - break; - } - - if ( - compositeCreatingComponentIdx.replacementOf - ?.attributes.createComponentIdx?.primitive - ?.value === component.componentIdx - ) { - compositeCreatingComponentIdx = - compositeCreatingComponentIdx.replacementOf; - } else { - break; - } - } - - if (foundUndeletedSourceOfComponentIdx) { - // We determined that the source of the component's component index - // is not being deleted, so don't remove the component from the resolver - continue; - } - } - - if ( - !(compositeSource.componentIdx in componentsToDelete) && - compositeSource.constructor.replacementsAlreadyInResolver - ) { - // don't remove from resolver, as non-deleted composite source - // already has replacements in the resolver, - // so the component was not added to the resolver when it was created - continue; - } - } + async addChildrenAndRecurseToShadows(args) { + return this.componentLifecycle.addChildrenAndRecurseToShadows(args); + } - componentsToRemoveFromResolver.push(component); + /** + * Create and insert `_error` siblings requested by state-variable definitions + * during initial document construction. + */ + async addQueuedErrorComponentsFromStateVariables() { + if (!this.errorComponentsToAdd?.length) { + return; } - this.removeComponentsFromResolver(componentsToRemoveFromResolver); - - for (const componentIdxStr in componentsToDelete) { - const componentIdx = Number(componentIdxStr); - let component = this._components[componentIdx]; + const errorComponentsToAdd = this.errorComponentsToAdd; + this.errorComponentsToAdd = []; - // delete from cumulativeStateVariableChanges - delete this.cumulativeStateVariableChanges[component.stateId]; + const numberInsertedAfterSource = {}; - // console.log(`deregistering ${componentIdx}`) + for (let errorInfo of errorComponentsToAdd) { + let sourceComponent = this._components[errorInfo.componentIdx]; + let parent; - // don't use recursive form since all children should already be included - this.deregisterComponent(component, false); + while (sourceComponent?.parentIdx > 0) { + const candidateParent = + this._components[sourceComponent.parentIdx]; - // remove deleted components from this.updateInfo sets - this.updateInfo.componentsToUpdateRenderers.delete(componentIdx); - this.updateInfo.compositesToUpdateReplacements.delete(componentIdx); - this.updateInfo.inactiveCompositesToUpdateReplacements.delete( - componentIdx, - ); - } + if (!candidateParent) { + break; + } - return { - success: true, - deletedComponents: componentsToDelete, - parentsOfDeleted: allParents, - }; - } + if (candidateParent.constructor.canDisplayChildErrors) { + parent = candidateParent; + break; + } - removeComponentsFromResolver(componentsToRemove) { - return this.resolverAdapter.removeComponentsFromResolver( - componentsToRemove, - ); - } + sourceComponent = candidateParent; + } - determineComponentsToDelete({ - components, - deleteUpstreamDependencies, - componentsToDelete, - }) { - for (let component of components) { - if (typeof component !== "object") { - continue; + if (!parent) { + if (this.document?.constructor.canDisplayChildErrors) { + parent = this.document; + } } - if (component.componentIdx in componentsToDelete) { + if (!parent) { continue; } - // add unproxied component - componentsToDelete[component.componentIdx] = - this._components[component.componentIdx]; + let indexOfDefiningChildren = parent.definingChildren.length; - // recurse on allChildren and attributes - let componentsToRecurse = Object.values(component.allChildren).map( - (x) => x.component, - ); + if (sourceComponent?.parentIdx === parent.componentIdx) { + const sourceInd = parent.definingChildren.findIndex( + (child) => + typeof child === "object" && + child.componentIdx === sourceComponent.componentIdx, + ); - for (let attrName in component.attributes) { - let comp = component.attributes[attrName].component; - if (comp) { - componentsToRecurse.push(comp); - } else { - let references = component.attributes[attrName].references; - if (references) { - componentsToRecurse.push(...references); - } + if (sourceInd !== -1) { + const numberAlreadyInserted = + numberInsertedAfterSource[ + sourceComponent.componentIdx + ] ?? 0; + indexOfDefiningChildren = + sourceInd + 1 + numberAlreadyInserted; + numberInsertedAfterSource[sourceComponent.componentIdx] = + numberAlreadyInserted + 1; } } - // if delete an adapter, also delete component it is adapting - if (component.adaptedFrom !== undefined) { - componentsToRecurse.push(component.adaptedFrom); - } + let serializedErrorComponents = [ + { + type: "serialized", + componentType: "_error", + componentIdx: this._components.length, + state: { message: errorInfo.message }, + position: errorInfo.position, + sourceDoc: errorInfo.sourceDoc, + children: [], + attributes: {}, + doenetAttributes: {}, + }, + ]; - if (deleteUpstreamDependencies === true) { - // TODO: recurse on copy of the component (other composites?) + this._components[this._components.length] = undefined; - // recurse on components that shadow - if (component.shadowedBy) { - componentsToRecurse.push(...component.shadowedBy); - } + let ancestors = [ + { + componentIdx: parent.componentIdx, + componentClass: parent.constructor, + }, + ...parent.ancestors, + ]; - // recurse on replacements and adapters - if (component.adapterUsed) { - componentsToRecurse.push(component.adapterUsed); - } - if (component.replacements) { - componentsToRecurse.push(...component.replacements); - } + this.parameterStack.push(parent.sharedParameters, false); + let createResult; + try { + createResult = await this.createIsolatedComponents({ + serializedComponents: serializedErrorComponents, + ancestors, + }); + } finally { + this.parameterStack.pop(); } - this.determineComponentsToDelete({ - components: componentsToRecurse, - deleteUpstreamDependencies, - componentsToDelete, + let addResults = await this.addChildrenAndRecurseToShadows({ + parent, + indexOfDefiningChildren, + newChildren: createResult.components, }); + + if (!addResults.success) { + throw Error( + "Couldn't add error component from state variable evaluation.", + ); + } } } + async processNewDefiningChildren(args) { + return this.componentLifecycle.processNewDefiningChildren(args); + } + + spliceChildren(parent, indexOfDefiningChildren, newChildren) { + return this.componentLifecycle.spliceChildren( + parent, + indexOfDefiningChildren, + newChildren, + ); + } + + // Component deletion lives in `this.deletionEngine` (see DeletionEngine.ts). + // The methods below preserve the public surface by delegating through. + + async deleteComponents(args) { + return this.deletionEngine.deleteComponents(args); + } + + + removeComponentsFromResolver(componentsToRemove) { + return this.resolverAdapter.removeComponentsFromResolver( + componentsToRemove, + ); + } + + determineComponentsToDelete(args) { + return this.deletionEngine.determineComponentsToDelete(args); + } + async updateCompositeReplacements({ component, componentChanges, @@ -10558,80 +9132,43 @@ export default class Core { return null; } - async executeProcesses() { - if (this.stopProcessingRequests) { - return; - } - - while (this.processQueue.length > 0) { - let nextUpdateInfo = this.processQueue.splice(0, 1)[0]; - let result; - try { - if (nextUpdateInfo.type === "update") { - if ( - !nextUpdateInfo.skippable || - this.processQueue.length < 2 - ) { - result = await this.performUpdate(nextUpdateInfo); - } + // The async request queue lives in `this.processQueueManager` + // (see ProcessQueue.ts). The accessors and methods below preserve the + // public surface (`core.processQueue`, `core.processing`, + // `core.stopProcessingRequests`, `core.executeProcesses`, + // `core.requestAction`, `core.requestUpdate`, `core.requestRecordEvent`) + // by delegating through. - // TODO: if skip an update, presumably we should call reject??? + get processQueue() { + return this.processQueueManager.queue; + } - // } else if (nextUpdateInfo.type === "getStateVariableValues") { - // result = await this.performGetStateVariableValues(nextUpdateInfo); - } else if (nextUpdateInfo.type === "action") { - if ( - !nextUpdateInfo.skippable || - this.processQueue.length < 2 - ) { - result = await this.performAction(nextUpdateInfo); - } + set processQueue(value) { + this.processQueueManager.queue = value; + } - // TODO: if skip an update, presumably we should call reject??? - } else if (nextUpdateInfo.type === "recordEvent") { - result = await this.performRecordEvent(nextUpdateInfo); - } else { - throw Error( - `Unrecognized process type: ${nextUpdateInfo.type}`, - ); - } + get processing() { + return this.processQueueManager.processing; + } - nextUpdateInfo.resolve(result); - } catch (e) { - console.error(e); - nextUpdateInfo.reject( - typeof e === "object" && - e && - "message" in e && - typeof e.message === "string" - ? e.message - : "Error in core", - ); - } - } + set processing(value) { + this.processQueueManager.processing = value; + } - this.processing = false; + get stopProcessingRequests() { + return this.processQueueManager.stopProcessingRequests; } - requestAction({ componentIdx, actionName, args }) { - return new Promise((resolve, reject) => { - let skippable = args?.skippable; + set stopProcessingRequests(value) { + this.processQueueManager.stopProcessingRequests = value; + } - this.processQueue.push({ - type: "action", - componentIdx, - actionName, - args, - skippable, - resolve, - reject, - }); + async executeProcesses() { + return this.processQueueManager.executeProcesses(); + } - if (!this.processing) { - this.processing = true; - this.executeProcesses(); - } - }); + requestAction(args) { + return this.processQueueManager.requestAction(args); } async performAction({ @@ -10727,79 +9264,8 @@ export default class Core { return {}; } - async triggerChainedActions({ - componentIdx, - triggeringAction, - actionId, - sourceInformation = {}, - skipRendererUpdate = false, - }) { - for (const cIdxStr in this.updateInfo - .componentsToUpdateActionChaining) { - await this.checkForActionChaining({ - component: this.components[cIdxStr], - stateVariables: - this.updateInfo.componentsToUpdateActionChaining[cIdxStr], - }); - } - - this.updateInfo.componentsToUpdateActionChaining = {}; - - let actionsToChain = []; - - let cIdx = componentIdx; - - while (true) { - let comp = this._components[cIdx]; - let id = cIdx; - - if (triggeringAction) { - id += "|" + triggeringAction; - } - - if (this.actionsChangedToActions[id]) { - actionsToChain.push(...this.actionsChangedToActions[id]); - } - - if (comp?.shadows) { - let composite = this._components[comp.shadows.compositeIdx]; - if (composite.attributes.createComponentOfType != null) { - break; - } - - // We propagate to shadows if the component was copied with a bare references such as `$P` - // but not if was copied via extend/copy attribute, such as ` - // Rationale: - // If we include $P in a graph, - // then triggerWhenObjectsClicked="$P" and triggerWhenObjectsFocused="$P" - // will be triggered by that reference, which is what authors would expect. - // Another use case is defining an , - // along with other triggered actions using triggerWith="$uv", - // inside a and then including a $uv - // where we want the button to be. - - cIdx = comp.shadows.componentIdx; - } else { - break; - } - } - - for (let chainedActionInstructions of actionsToChain) { - chainedActionInstructions = { ...chainedActionInstructions }; - if (chainedActionInstructions.args) { - chainedActionInstructions.args = { - ...chainedActionInstructions.args, - }; - } else { - chainedActionInstructions.args = {}; - } - chainedActionInstructions.args.skipRendererUpdate = true; - await this.performAction(chainedActionInstructions); - } - - if (!skipRendererUpdate) { - await this.updateAllChangedRenderers(sourceInformation, actionId); - } + async triggerChainedActions(args) { + return this.actionTriggerScheduler.triggerChainedActions(args); } async updateRenderers({ @@ -10812,86 +9278,8 @@ export default class Core { } } - async requestUpdate({ - updateInstructions, - transient = false, - event, - skippable = false, - overrideReadOnly = false, - }) { - // Note: the transient flag is now ignored - // as the debounce is preventing too many updates from occurring - - if (this.flags.readOnly && !overrideReadOnly) { - let sourceInformation = {}; - - for (let instruction of updateInstructions) { - let componentSourceInformation = - sourceInformation[instruction.componentIdx]; - if (!componentSourceInformation) { - componentSourceInformation = sourceInformation[ - instruction.componentIdx - ] = {}; - } - - if (instruction.sourceDetails) { - Object.assign( - componentSourceInformation, - instruction.sourceDetails, - ); - } - } - - await this.updateRendererInstructions({ - componentNamesToUpdate: updateInstructions.map( - (x) => x.componentIdx, - ), - sourceOfUpdate: { sourceInformation }, - }); - - return; - } - - return new Promise((resolve, reject) => { - this.processQueue.push({ - type: "update", - updateInstructions, - transient, - event, - skippable, - resolve, - reject, - }); - - if (!this.processing) { - this.processing = true; - this.executeProcesses(); - } - - // if (this.processing) { - - // } else { - // this.processing = true; - - // // Note: execute this process synchronously - // // so that UI doesn't update until after finished. - // // It is a tradeoff, as the UI has to wait, - // // but it allows constraints to be applied before renderering. - - // this.performUpdate({ updateInstructions, transient, event }).then(() => { - // // execute asynchronously any remaining processes - // // (that got added while performUpdate was running) - - // // if (this.processQueue.length > 0) { - // setTimeout(this.executeProcesses, 0); - // // } else { - // // this.processing = false; - // // } - // resolve(); - // }); - - // } - }); + async requestUpdate(args) { + return this.processQueueManager.requestUpdate(args); } async performUpdate({ @@ -11187,58 +9575,14 @@ export default class Core { } async updateAllChangedRenderers(sourceInformation = {}, actionId) { - let componentNamesToUpdate = [ - ...this.updateInfo.componentsToUpdateRenderers, - ]; - this.updateInfo.componentsToUpdateRenderers.clear(); - - await this.updateRendererInstructions({ - componentNamesToUpdate, - sourceOfUpdate: { sourceInformation, local: true }, + return this.rendererInstructionBuilder.updateAllChangedRenderers( + sourceInformation, actionId, - }); - - // updating renderer instructions could trigger more composite updates - // (presumably from deriving child results) - // if so, make replacement changes and update renderer instructions again - // TODO: should we check for child results earlier so we don't have to check them - // when updating renderer instructions? - if (this.updateInfo.compositesToUpdateReplacements.size > 0) { - await this.replacementChangesFromCompositesToUpdate(); - - let componentNamesToUpdate = [ - ...this.updateInfo.componentsToUpdateRenderers, - ]; - this.updateInfo.componentsToUpdateRenderers.clear(); - - await this.updateRendererInstructions({ - componentNamesToUpdate, - sourceOfUpdate: { sourceInformation, local: true }, - actionId, - }); - } + ); } requestRecordEvent(event) { - this.resumeVisibilityMeasuring(); - - if (event.verb === "visibilityChanged") { - return this.processVisibilityChangedEvent(event); - } - - return new Promise((resolve, reject) => { - this.processQueue.push({ - type: "recordEvent", - event, - resolve, - reject, - }); - - if (!this.processing) { - this.processing = true; - this.executeProcesses(); - } - }); + return this.processQueueManager.requestRecordEvent(event); } async performRecordEvent({ event }) { diff --git a/packages/doenetml-worker-javascript/src/DeletionEngine.ts b/packages/doenetml-worker-javascript/src/DeletionEngine.ts new file mode 100644 index 000000000..30e17e39f --- /dev/null +++ b/packages/doenetml-worker-javascript/src/DeletionEngine.ts @@ -0,0 +1,397 @@ +/** + * Deletes components from the live tree. The bulk of the work is the + * two-phase walk: + * + * 1. `determineComponentsToDelete` collects the full set of components + * that must go (children, attribute components, adapters, shadows, + * replacements) when an upstream dependent is deleted. + * 2. `deleteComponents` then unlinks parents, splices replacements out + * of composite sources, deletes dependency edges, removes nodes from + * the resolver, deregisters from `core._components`, and clears + * `updateInfo` queues for the deleted ids. + * + * Stateless. Holds a back-reference to Core to read `_components`, + * `dependencies`, `updateInfo`, `unmatchedChildren`, + * `cumulativeStateVariableChanges`, `stateVariableChangeTriggers`, + * `componentsToRender`, and to invoke `processNewDefiningChildren`, + * `removeComponentsFromResolver`, `deregisterComponent`, + * `deleteFromComponentsToRender`. + */ +export class DeletionEngine { + core: any; + + constructor({ core }: { core: any }) { + this.core = core; + } + + async deleteComponents({ + components, + deleteUpstreamDependencies = true, + skipProcessingChildrenOfParents = [], + }: { + components: any | any[]; + deleteUpstreamDependencies?: boolean; + skipProcessingChildrenOfParents?: number[]; + }): Promise { + // to delete a component, one must + // 1. recursively delete all children and attribute components + // 3. should we delete or mark components who are upstream dependencies? + // 4. for all other downstream dependencies, + // delete upstream link back to component + + if (!Array.isArray(components)) { + components = [components]; + } + + // TODO: if delete a shadow directly it should be an error + // (though it will be OK to delete them through other side effects) + + // step 1. Determine which components to delete + const componentsToDelete: Record = {}; + this.determineComponentsToDelete({ + components, + deleteUpstreamDependencies, + componentsToDelete, + }); + + //Calculate parent set + const parentsOfPotentiallyDeleted: Record< + number, + { parent: any; childNamesToBeDeleted: Set } + > = {}; + for (const componentIdxStr in componentsToDelete) { + const componentIdx = Number(componentIdxStr); + let component = componentsToDelete[componentIdx]; + let parent = this.core.components[component.parentIdx]; + + // only add parent if it is not in componentsToDelete itself + if ( + parent === undefined || + parent.componentIdx in componentsToDelete + ) { + continue; + } + let parentObj = parentsOfPotentiallyDeleted[component.parentIdx]; + if (parentObj === undefined) { + parentObj = { + parent: this.core._components[component.parentIdx], + childNamesToBeDeleted: new Set(), + }; + parentsOfPotentiallyDeleted[component.parentIdx] = parentObj; + } + parentObj.childNamesToBeDeleted.add(componentIdx); + } + + // if component is a replacement of another component, + // need to delete component from the replacement + // so that it isn't added back as a child of its parent + // Also keep track of which ones deleted so can add back to replacements + // if the deletion is unsuccessful + let replacementsDeletedFromComposites: number[] = []; + + for (const componentIdxStr in componentsToDelete) { + const componentIdx = Number(componentIdxStr); + let component = this.core._components[componentIdx]; + if (component.replacementOf) { + let composite = component.replacementOf; + + let replacementNames = composite.replacements.map( + (x: any) => x.componentIdx, + ); + + let replacementInd = replacementNames.indexOf(componentIdx); + if (replacementInd !== -1) { + composite.replacements.splice(replacementInd, 1); + if ( + !replacementsDeletedFromComposites.includes( + composite.componentIdx, + ) + ) { + replacementsDeletedFromComposites.push( + composite.componentIdx, + ); + } + } + } + } + + for (const compositeIdxStr of replacementsDeletedFromComposites) { + if (!(compositeIdxStr in componentsToDelete)) { + await this.core.dependencies.addBlockersFromChangedReplacements( + this.core._components[compositeIdxStr], + ); + } + } + + // delete component from parent's defining children + // and record parents + const allParents: any[] = []; + for (const parentIdxStr in parentsOfPotentiallyDeleted) { + const parentObj = parentsOfPotentiallyDeleted[parentIdxStr]; + const parent = parentObj.parent; + allParents.push(parent); + + for ( + let ind = parent.definingChildren.length - 1; + ind >= 0; + ind-- + ) { + const child = parent.definingChildren[ind]; + if (parentObj.childNamesToBeDeleted.has(child.componentIdx)) { + parent.definingChildren.splice(ind, 1); // delete from array + } + } + + if ( + !skipProcessingChildrenOfParents.includes(parent.componentIdx) + ) { + await this.core.processNewDefiningChildren({ + parent, + expandComposites: false, + }); + } + } + + for (const componentIdxStr in componentsToDelete) { + const componentIdx = Number(componentIdxStr); + const component = this.core._components[componentIdx]; + + if (component.shadows) { + const shadowedComponent = + this.core._components[component.shadows.componentIdx]; + if (shadowedComponent.shadowedBy.length === 1) { + delete shadowedComponent.shadowedBy; + } else { + shadowedComponent.shadowedBy.splice( + shadowedComponent.shadowedBy.indexOf(component), + 1, + ); + } + } + + this.core.dependencies.deleteAllDownstreamDependencies({ + component, + }); + + // record any upstream dependencies that depend directly on componentIdx + // (componentIdentity, componentStateVariable*) + + for (let varName in this.core.dependencies.upstreamDependencies[ + component.componentIdx + ]) { + let upDeps = + this.core.dependencies.upstreamDependencies[ + component.componentIdx + ][varName]; + for (let upDep of upDeps) { + if ( + upDep.specifiedComponentName && + upDep.specifiedComponentName in componentsToDelete + ) { + let dependenciesMissingComponent = + this.core.dependencies.updateTriggers + .dependenciesMissingComponentBySpecifiedName[ + upDep.specifiedComponentName + ]; + if (!dependenciesMissingComponent) { + dependenciesMissingComponent = + this.core.dependencies.updateTriggers.dependenciesMissingComponentBySpecifiedName[ + upDep.specifiedComponentName + ] = []; + } + if (!dependenciesMissingComponent.includes(upDep)) { + dependenciesMissingComponent.push(upDep); + } + } + } + } + + await this.core.dependencies.deleteAllUpstreamDependencies({ + component, + }); + + if ( + !this.core.updateInfo.deletedStateVariables[ + component.componentIdx + ] + ) { + this.core.updateInfo.deletedStateVariables[ + component.componentIdx + ] = []; + } + this.core.updateInfo.deletedStateVariables[ + component.componentIdx + ].push(...Object.keys(component.state)); + + this.core.updateInfo.deletedComponents[component.componentIdx] = + true; + delete this.core.unmatchedChildren[component.componentIdx]; + + delete this.core.stateVariableChangeTriggers[ + component.componentIdx + ]; + } + + const componentsToRemoveFromResolver: any[] = []; + + for (const componentIdxStr in componentsToDelete) { + const componentIdx = Number(componentIdxStr); + let component = this.core._components[componentIdx]; + + if (component.replacementOf) { + const compositeSource = component.replacementOf; + + if ( + compositeSource.attributes.createComponentIdx?.primitive + ?.value == component.componentIdx + ) { + // If the component's index is being created from a composite, + // check if there is a source of that composite index that is not being deleted. + // In that case, we should not remove the component from the resolver. + let compositeCreatingComponentIdx = compositeSource; + + let foundUndeletedSourceOfComponentIdx = false; + while (true) { + if ( + !( + compositeCreatingComponentIdx.componentIdx in + componentsToDelete + ) + ) { + foundUndeletedSourceOfComponentIdx = true; + break; + } + + if ( + compositeCreatingComponentIdx.replacementOf + ?.attributes.createComponentIdx?.primitive + ?.value === component.componentIdx + ) { + compositeCreatingComponentIdx = + compositeCreatingComponentIdx.replacementOf; + } else { + break; + } + } + + if (foundUndeletedSourceOfComponentIdx) { + // We determined that the source of the component's component index + // is not being deleted, so don't remove the component from the resolver + continue; + } + } + + if ( + !(compositeSource.componentIdx in componentsToDelete) && + compositeSource.constructor.replacementsAlreadyInResolver + ) { + // don't remove from resolver, as non-deleted composite source + // already has replacements in the resolver, + // so the component was not added to the resolver when it was created + continue; + } + } + + componentsToRemoveFromResolver.push(component); + } + + this.core.removeComponentsFromResolver(componentsToRemoveFromResolver); + + for (const componentIdxStr in componentsToDelete) { + const componentIdx = Number(componentIdxStr); + let component = this.core._components[componentIdx]; + + // delete from cumulativeStateVariableChanges + delete this.core.cumulativeStateVariableChanges[component.stateId]; + + // don't use recursive form since all children should already be included + this.core.deregisterComponent(component, false); + + // remove deleted components from this.updateInfo sets + this.core.updateInfo.componentsToUpdateRenderers.delete( + componentIdx, + ); + this.core.updateInfo.compositesToUpdateReplacements.delete( + componentIdx, + ); + this.core.updateInfo.inactiveCompositesToUpdateReplacements.delete( + componentIdx, + ); + } + + return { + success: true, + deletedComponents: componentsToDelete, + parentsOfDeleted: allParents, + }; + } + + determineComponentsToDelete({ + components, + deleteUpstreamDependencies, + componentsToDelete, + }: { + components: any[]; + deleteUpstreamDependencies: boolean; + componentsToDelete: Record; + }): void { + for (let component of components) { + if (typeof component !== "object") { + continue; + } + + if (component.componentIdx in componentsToDelete) { + continue; + } + + // add unproxied component + componentsToDelete[component.componentIdx] = + this.core._components[component.componentIdx]; + + // recurse on allChildren and attributes + let componentsToRecurse = Object.values( + component.allChildren as Record, + ).map((x) => x.component); + + for (let attrName in component.attributes) { + let comp = component.attributes[attrName].component; + if (comp) { + componentsToRecurse.push(comp); + } else { + let references = component.attributes[attrName].references; + if (references) { + componentsToRecurse.push(...references); + } + } + } + + // if delete an adapter, also delete component it is adapting + if (component.adaptedFrom !== undefined) { + componentsToRecurse.push(component.adaptedFrom); + } + + if (deleteUpstreamDependencies === true) { + // TODO: recurse on copy of the component (other composites?) + + // recurse on components that shadow + if (component.shadowedBy) { + componentsToRecurse.push(...component.shadowedBy); + } + + // recurse on replacements and adapters + if (component.adapterUsed) { + componentsToRecurse.push(component.adapterUsed); + } + if (component.replacements) { + componentsToRecurse.push(...component.replacements); + } + } + + this.determineComponentsToDelete({ + components: componentsToRecurse, + deleteUpstreamDependencies, + componentsToDelete, + }); + } + } +} diff --git a/packages/doenetml-worker-javascript/src/ProcessQueue.ts b/packages/doenetml-worker-javascript/src/ProcessQueue.ts new file mode 100644 index 000000000..701ff0b9c --- /dev/null +++ b/packages/doenetml-worker-javascript/src/ProcessQueue.ts @@ -0,0 +1,220 @@ +type QueueEntry = + | { + type: "update"; + updateInstructions: any; + transient: boolean; + event: any; + skippable: boolean; + resolve: (v?: any) => void; + reject: (v?: any) => void; + } + | { + type: "action"; + componentIdx: number; + actionName: string; + args: any; + skippable?: boolean; + resolve: (v?: any) => void; + reject: (v?: any) => void; + } + | { + type: "recordEvent"; + event: any; + resolve: (v?: any) => void; + reject: (v?: any) => void; + }; + +/** + * Owns the asynchronous request queue: the entry points + * (`requestAction`, `requestUpdate`, `requestRecordEvent`) push work + * onto the queue, and `executeProcesses` drains it serially by + * dispatching to Core's `performAction` / `performUpdate` / + * `performRecordEvent` (still on Core through Phase 3). + * + * Holds a back-reference to Core to read `flags`, invoke the perform + * methods, run renderer-only short-circuits in read-only mode, resume + * visibility measuring on event recording, and route visibility events. + */ +export class ProcessQueue { + core: any; + queue: QueueEntry[]; + processing: boolean; + stopProcessingRequests: boolean; + + constructor({ core }: { core: any }) { + this.core = core; + this.queue = []; + this.processing = false; + this.stopProcessingRequests = false; + } + + async executeProcesses(): Promise { + if (this.stopProcessingRequests) { + return; + } + + while (this.queue.length > 0) { + let nextUpdateInfo = this.queue.splice(0, 1)[0]; + let result; + try { + if (nextUpdateInfo.type === "update") { + if ( + !nextUpdateInfo.skippable || + this.queue.length < 2 + ) { + result = await this.core.performUpdate(nextUpdateInfo); + } + + // TODO: if skip an update, presumably we should call reject??? + + // } else if (nextUpdateInfo.type === "getStateVariableValues") { + // result = await this.core.performGetStateVariableValues(nextUpdateInfo); + } else if (nextUpdateInfo.type === "action") { + if ( + !nextUpdateInfo.skippable || + this.queue.length < 2 + ) { + result = await this.core.performAction(nextUpdateInfo); + } + + // TODO: if skip an update, presumably we should call reject??? + } else if (nextUpdateInfo.type === "recordEvent") { + result = await this.core.performRecordEvent(nextUpdateInfo); + } else { + throw Error( + `Unrecognized process type: ${(nextUpdateInfo as any).type}`, + ); + } + + nextUpdateInfo.resolve(result); + } catch (e) { + console.error(e); + nextUpdateInfo.reject( + typeof e === "object" && + e && + "message" in e && + typeof (e as any).message === "string" + ? (e as any).message + : "Error in core", + ); + } + } + + this.processing = false; + } + + requestAction({ + componentIdx, + actionName, + args, + }: { + componentIdx: number; + actionName: string; + args?: any; + }): Promise { + return new Promise((resolve, reject) => { + let skippable = args?.skippable; + + this.queue.push({ + type: "action", + componentIdx, + actionName, + args, + skippable, + resolve, + reject, + }); + + if (!this.processing) { + this.processing = true; + this.executeProcesses(); + } + }); + } + + async requestUpdate({ + updateInstructions, + transient = false, + event, + skippable = false, + overrideReadOnly = false, + }: { + updateInstructions: any[]; + transient?: boolean; + event?: any; + skippable?: boolean; + overrideReadOnly?: boolean; + }): Promise { + // Note: the transient flag is now ignored + // as the debounce is preventing too many updates from occurring + + if (this.core.flags.readOnly && !overrideReadOnly) { + let sourceInformation: Record = {}; + + for (let instruction of updateInstructions) { + let componentSourceInformation = + sourceInformation[instruction.componentIdx]; + if (!componentSourceInformation) { + componentSourceInformation = sourceInformation[ + instruction.componentIdx + ] = {}; + } + + if (instruction.sourceDetails) { + Object.assign( + componentSourceInformation, + instruction.sourceDetails, + ); + } + } + + await this.core.updateRendererInstructions({ + componentNamesToUpdate: updateInstructions.map( + (x) => x.componentIdx, + ), + sourceOfUpdate: { sourceInformation }, + }); + + return; + } + + return new Promise((resolve, reject) => { + this.queue.push({ + type: "update", + updateInstructions, + transient, + event, + skippable, + resolve, + reject, + }); + + if (!this.processing) { + this.processing = true; + this.executeProcesses(); + } + }); + } + + requestRecordEvent(event: any): Promise | undefined { + this.core.resumeVisibilityMeasuring(); + + if (event.verb === "visibilityChanged") { + return this.core.processVisibilityChangedEvent(event); + } + + return new Promise((resolve, reject) => { + this.queue.push({ + type: "recordEvent", + event, + resolve, + reject, + }); + + if (!this.processing) { + this.processing = true; + this.executeProcesses(); + } + }); + } +} diff --git a/packages/doenetml-worker-javascript/src/RendererInstructionBuilder.ts b/packages/doenetml-worker-javascript/src/RendererInstructionBuilder.ts new file mode 100644 index 000000000..6f89a5af9 --- /dev/null +++ b/packages/doenetml-worker-javascript/src/RendererInstructionBuilder.ts @@ -0,0 +1,512 @@ +import { removeFunctionsMathExpressionClass } from "./utils/math"; + +/** + * Builds the dast/instruction stream sent to the renderer. Owns the + * per-component "what's currently rendered" registry, the cached + * renderer state used for save/restore, and the queue of components + * whose child lists have changed since the last flush. + * + * Holds a back-reference to Core to read the live component tree, + * the `updateInfo.componentsToUpdateRenderers` queue, root names, + * and to invoke `updateRenderersCallback`. Calls into Core's + * `deriveChildResultsFromDefiningChildren`, `returnActiveChildrenIndicesToRender`, + * and `replacementChangesFromCompositesToUpdate` (slated for later phases). + */ +export class RendererInstructionBuilder { + core: any; + componentsToRender: Record; + componentsWithChangedChildrenToRender: Set; + rendererState: Record; + + constructor({ core }: { core: any }) { + this.core = core; + this.componentsToRender = {}; + this.componentsWithChangedChildrenToRender = new Set(); + this.rendererState = {}; + } + + /** + * Clear all per-document renderer state. Called from `Core.generateDast` + * so that state from any previous run does not leak into a fresh document. + */ + reset(): void { + this.componentsToRender = {}; + this.componentsWithChangedChildrenToRender = new Set(); + this.rendererState = {}; + } + + callUpdateRenderers(args: any, init = false): void { + let diagnostics: any = undefined; + if (this.core.hasPendingDiagnostics) { + diagnostics = this.core.getDiagnostics().diagnostics; + } + + this.core.updateRenderersCallback({ ...args, init, diagnostics }); + } + + async updateRendererInstructions({ + componentNamesToUpdate, + sourceOfUpdate = {}, + actionId, + }: { + componentNamesToUpdate: number[]; + sourceOfUpdate?: any; + actionId?: string; + }): Promise { + let deletedRenderers: any[] = []; + + let updateInstructions: any[] = []; + let rendererStatesToUpdate: any[] = []; + + let newChildrenInstructions: Record = {}; + + // copy components with changed children and reset for next time + let componentsWithChangedChildrenToRenderInProgress = + this.componentsWithChangedChildrenToRender; + this.componentsWithChangedChildrenToRender = new Set(); + + //TODO: Figure out what we need from here + for (let componentIdx of componentsWithChangedChildrenToRenderInProgress) { + if (componentIdx in this.componentsToRender) { + // check to see if current children who render are + // different from last time rendered + + let currentChildIdentifiers: string[] = []; + let unproxiedComponent = this.core._components[componentIdx]; + let indicesToRender: number[] = []; + + if ( + unproxiedComponent && + unproxiedComponent.constructor.renderChildren + ) { + if (!unproxiedComponent.matchedCompositeChildren) { + await this.core.deriveChildResultsFromDefiningChildren({ + parent: unproxiedComponent, + expandComposites: true, + forceExpandComposites: true, + }); + } + + indicesToRender = + await this.core.returnActiveChildrenIndicesToRender( + unproxiedComponent, + ); + + let renderedInd = 0; + for (let [ + ind, + child, + ] of unproxiedComponent.activeChildren.entries() as Iterable< + [number, any] + >) { + if (indicesToRender.includes(ind)) { + if (child.rendererType) { + currentChildIdentifiers.push( + `nameType:${child.componentIdx};${child.componentType}`, + ); + renderedInd++; + } else if (typeof child === "string") { + currentChildIdentifiers.push( + `string${renderedInd}:${child}`, + ); + renderedInd++; + } else if (typeof child === "number") { + currentChildIdentifiers.push( + `number${renderedInd}:${( + child as number + ).toString()}`, + ); + renderedInd++; + } else { + currentChildIdentifiers.push(""); + } + } else { + currentChildIdentifiers.push(""); + } + } + } + + let previousChildRenderers = + this.componentsToRender[componentIdx].children; + + let previousChildIdentifiers: string[] = []; + for (let [ind, child] of previousChildRenderers.entries() as Iterable< + [number, any] + >) { + if (child === null) { + previousChildIdentifiers.push(""); + } else if (child.componentIdx != undefined) { + previousChildIdentifiers.push( + `nameType:${child.componentIdx};${child.componentType}`, + ); + } else if (typeof child === "string") { + previousChildIdentifiers.push(`string${ind}:${child}`); + } else if (typeof child === "number") { + previousChildIdentifiers.push( + `number${ind}:${(child as number).toString()}`, + ); + } + } + + if ( + currentChildIdentifiers.length !== + previousChildIdentifiers.length || + currentChildIdentifiers.some( + (v, i) => v !== previousChildIdentifiers[i], + ) + ) { + // delete old renderers + for (let child of previousChildRenderers) { + if (child?.componentIdx != undefined) { + let deletedNames = this.deleteFromComponentsToRender( + { + componentIdx: child.componentIdx, + recurseToChildren: true, + componentsWithChangedChildrenToRenderInProgress, + }, + ); + deletedRenderers.push(...deletedNames); + } + } + + // create new renderers + let childrenToRender: any[] = []; + if (indicesToRender.length > 0) { + for (let [ + ind, + child, + ] of unproxiedComponent.activeChildren.entries() as Iterable< + [number, any] + >) { + if (indicesToRender.includes(ind)) { + if (child.rendererType) { + let results = + await this.initializeRenderedComponentInstruction( + child, + componentsWithChangedChildrenToRenderInProgress, + ); + childrenToRender.push( + results.componentToRender, + ); + rendererStatesToUpdate.push( + ...results.rendererStatesToUpdate, + ); + } else if (typeof child === "string") { + childrenToRender.push(child); + } else if (typeof child === "number") { + childrenToRender.push( + (child as number).toString(), + ); + } else { + childrenToRender.push(null); + } + } else { + childrenToRender.push(null); + } + } + } + + this.componentsToRender[componentIdx].children = + childrenToRender; + + newChildrenInstructions[componentIdx] = childrenToRender; + + componentsWithChangedChildrenToRenderInProgress.delete( + componentIdx, + ); + + if (!componentNamesToUpdate.includes(componentIdx)) { + componentNamesToUpdate.push(componentIdx); + } + } + } + } + + for (let componentIdx of componentNamesToUpdate) { + if ( + componentIdx in this.componentsToRender + // && !deletedRenderers.includes(componentIdx) TODO: what if recreate with same name? + ) { + let component = this.core._components[componentIdx]; + if (component) { + let stateValuesForRenderer: Record = {}; + for (let stateVariable in component.state) { + if (component.state[stateVariable].forRenderer) { + let value = removeFunctionsMathExpressionClass( + await component.state[stateVariable].value, + ); + stateValuesForRenderer[stateVariable] = value; + } + } + + if (component.compositeReplacementActiveRange) { + stateValuesForRenderer._compositeReplacementActiveRange = + component.compositeReplacementActiveRange; + } + + let newRendererState: any = { + componentIdx, + stateValues: stateValuesForRenderer, + rendererType: component.rendererType, // TODO: need this to ignore baseVariables change: is this right place? + }; + + // this.renderState is used to save the renderer state to the database + if (!this.rendererState[componentIdx]) { + this.rendererState[componentIdx] = {}; + } + + this.rendererState[componentIdx].stateValues = + stateValuesForRenderer; + + // only add childrenInstructions if they changed + if (newChildrenInstructions[componentIdx]) { + newRendererState.childrenInstructions = + newChildrenInstructions[componentIdx]; + this.rendererState[componentIdx].childrenInstructions = + newChildrenInstructions[componentIdx]; + } + + rendererStatesToUpdate.push(newRendererState); + } + } + } + + // rendererStatesToUpdate = rendererStatesToUpdate.filter(x => !deletedRenderers.includes(x)) + if (rendererStatesToUpdate.length > 0) { + let instruction = { + instructionType: "updateRendererStates", + rendererStatesToUpdate, + sourceOfUpdate, + }; + updateInstructions.splice(0, 0, instruction); + } + + this.callUpdateRenderers({ updateInstructions, actionId }); + } + + async initializeRenderedComponentInstruction( + component: any, + componentsWithChangedChildrenToRenderInProgress: Set = new Set(), + ): Promise { + if (component.rendererType === undefined) { + return; + } + + if (!component.matchedCompositeChildren) { + await this.core.deriveChildResultsFromDefiningChildren({ + parent: component, + expandComposites: true, //forceExpandComposites: true, + }); + } + + let rendererStatesToUpdate: any[] = []; + let rendererStatesToForceUpdate: any[] = []; + + let stateValuesForRenderer: Record = {}; + let stateValuesForRendererAlwaysUpdate: Record = {}; + let alwaysUpdate = false; + for (let stateVariable in component.state) { + if (component.state[stateVariable].forRenderer) { + stateValuesForRenderer[stateVariable] = + removeFunctionsMathExpressionClass( + await component.state[stateVariable].value, + ); + if (component.state[stateVariable].alwaysUpdateRenderer) { + alwaysUpdate = true; + } + } + } + + if (component.compositeReplacementActiveRange) { + stateValuesForRenderer._compositeReplacementActiveRange = + component.compositeReplacementActiveRange; + } + + if (alwaysUpdate) { + stateValuesForRendererAlwaysUpdate = stateValuesForRenderer; + } + + let componentIdx = component.componentIdx; + + let childrenToRender: any[] = []; + if (component.constructor.renderChildren) { + let indicesToRender = + await this.core.returnActiveChildrenIndicesToRender(component); + for (let [ind, child] of component.activeChildren.entries() as Iterable< + [number, any] + >) { + if (indicesToRender.includes(ind)) { + if (child.rendererType) { + let results = + await this.initializeRenderedComponentInstruction( + child, + componentsWithChangedChildrenToRenderInProgress, + ); + childrenToRender.push(results.componentToRender); + rendererStatesToUpdate.push( + ...results.rendererStatesToUpdate, + ); + rendererStatesToForceUpdate.push( + ...results.rendererStatesToForceUpdate, + ); + } else if (typeof child === "string") { + childrenToRender.push(child); + } else if (typeof child === "number") { + childrenToRender.push((child as number).toString()); + } else { + childrenToRender.push(null); + } + } else { + childrenToRender.push(null); + } + } + } + + rendererStatesToUpdate.push({ + componentIdx, + stateValues: stateValuesForRenderer, + childrenInstructions: childrenToRender, + }); + if (Object.keys(stateValuesForRendererAlwaysUpdate).length > 0) { + rendererStatesToForceUpdate.push({ + componentIdx, + stateValues: stateValuesForRendererAlwaysUpdate, + }); + } + + // this.renderState is used to save the renderer state to the database + this.rendererState[componentIdx] = { + stateValues: stateValuesForRenderer, + childrenInstructions: childrenToRender, + }; + + componentsWithChangedChildrenToRenderInProgress.delete(componentIdx); + + let requestActions: Record = {}; + for (let actionName in component.actions) { + requestActions[actionName] = { + actionName, + componentIdx: component.componentIdx, + }; + } + + for (let actionName in component.externalActions) { + let action = await component.externalActions[actionName]; + if (action) { + requestActions[actionName] = { + actionName, + componentIdx: action.componentIdx, + }; + } + } + + let rendererInstructions = { + componentIdx: componentIdx, + effectiveIdx: component.componentOrAdaptedIdx, + id: this.getRendererId(component), + componentType: component.componentType, + rendererType: component.rendererType, + actions: requestActions, + }; + + this.componentsToRender[componentIdx] = { + children: childrenToRender, + }; + + return { + componentToRender: rendererInstructions, + rendererStatesToUpdate, + rendererStatesToForceUpdate, + }; + } + + /** + * Get the `rendererId` of `component`, + * where `rendererId` is the `rootName` of the component, if it exists, + * else the `componentIdx` as a string. + * + * The `rootName` is the simplest unique reference to the component + * when the document root is the origin. As `rootName` is designed to be + * a HTML id, indices are represented with `:`. For example, + * if `$a.b[2][3].c` is the simplest reference to a component from the root, + * then its root name will be `a.b:2:3.c`. + * + * If a component was adapted from another component, + * then the `renderedId` of the original component is used instead, + * as that corresponds to the component that was authored. + */ + getRendererId(component: any): string { + return ( + this.core.rootNames?.[component.componentOrAdaptedIdx] ?? + `_id_${component.componentOrAdaptedIdx.toString()}` + ); + } + + deleteFromComponentsToRender({ + componentIdx, + recurseToChildren = true, + componentsWithChangedChildrenToRenderInProgress, + }: { + componentIdx: number; + recurseToChildren?: boolean; + componentsWithChangedChildrenToRenderInProgress: Set; + }): number[] { + let deletedComponentNames: number[] = [componentIdx]; + if (recurseToChildren) { + let componentInstruction = this.componentsToRender[componentIdx]; + if (componentInstruction) { + for (let child of componentInstruction.children) { + if (child) { + let additionalDeleted = + this.deleteFromComponentsToRender({ + componentIdx: child.componentIdx, + recurseToChildren, + componentsWithChangedChildrenToRenderInProgress, + }); + deletedComponentNames.push(...additionalDeleted); + } + } + } + } + delete this.componentsToRender[componentIdx]; + componentsWithChangedChildrenToRenderInProgress.delete(componentIdx); + + return deletedComponentNames; + } + + async updateAllChangedRenderers( + sourceInformation: any = {}, + actionId?: string, + ): Promise { + let componentNamesToUpdate = [ + ...this.core.updateInfo.componentsToUpdateRenderers, + ]; + this.core.updateInfo.componentsToUpdateRenderers.clear(); + + await this.updateRendererInstructions({ + componentNamesToUpdate, + sourceOfUpdate: { sourceInformation, local: true }, + actionId, + }); + + // updating renderer instructions could trigger more composite updates + // (presumably from deriving child results) + // if so, make replacement changes and update renderer instructions again + // TODO: should we check for child results earlier so we don't have to check them + // when updating renderer instructions? + if (this.core.updateInfo.compositesToUpdateReplacements.size > 0) { + await this.core.replacementChangesFromCompositesToUpdate(); + + let componentNamesToUpdate = [ + ...this.core.updateInfo.componentsToUpdateRenderers, + ]; + this.core.updateInfo.componentsToUpdateRenderers.clear(); + + await this.updateRendererInstructions({ + componentNamesToUpdate, + sourceOfUpdate: { sourceInformation, local: true }, + actionId, + }); + } + } +} From 865e9639ca99e682b44d991d3f238449efec4a83 Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Fri, 1 May 2026 14:54:11 -0500 Subject: [PATCH 5/7] prettier Co-Authored-By: Claude Haiku 4.5 --- .../src/ChildMatcher.ts | 20 +++++++++---------- .../doenetml-worker-javascript/src/Core.js | 7 +++++-- .../src/ProcessQueue.ts | 10 ++-------- .../src/RendererInstructionBuilder.ts | 17 ++++++++++------ 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/doenetml-worker-javascript/src/ChildMatcher.ts b/packages/doenetml-worker-javascript/src/ChildMatcher.ts index be91715f2..ef6c5492c 100644 --- a/packages/doenetml-worker-javascript/src/ChildMatcher.ts +++ b/packages/doenetml-worker-javascript/src/ChildMatcher.ts @@ -32,9 +32,7 @@ export class ChildMatcher { expandComposites?: boolean; forceExpandComposites?: boolean; }): Promise { - if ( - this.derivingChildResultsInProgress.includes(parent.componentIdx) - ) { + if (this.derivingChildResultsInProgress.includes(parent.componentIdx)) { return { success: false, skipping: true }; } this.derivingChildResultsInProgress.push(parent.componentIdx); @@ -185,9 +183,9 @@ export class ChildMatcher { let unmatchedChildren: any[] = []; - for (let [ind, child] of ( - parent.activeChildren.entries() as Iterable<[number, any]> - )) { + for (let [ind, child] of parent.activeChildren.entries() as Iterable< + [number, any] + >) { let childType = typeof child !== "object" ? typeof child : child.componentType; @@ -313,7 +311,9 @@ export class ChildMatcher { return { success: false }; } - async returnActiveChildrenIndicesToRender(component: any): Promise { + async returnActiveChildrenIndicesToRender( + component: any, + ): Promise { let indicesToRender: number[] = []; let numChildrenToRender = Infinity; if ("numChildrenToRender" in component.state) { @@ -326,9 +326,9 @@ export class ChildMatcher { await component.stateValues.childIndicesToRender; } - for (let [ind, child] of ( - component.activeChildren.entries() as Iterable<[number, any]> - )) { + for (let [ind, child] of component.activeChildren.entries() as Iterable< + [number, any] + >) { if (ind >= numChildrenToRender) { break; } diff --git a/packages/doenetml-worker-javascript/src/Core.js b/packages/doenetml-worker-javascript/src/Core.js index 2976c4b72..da107bb8c 100644 --- a/packages/doenetml-worker-javascript/src/Core.js +++ b/packages/doenetml-worker-javascript/src/Core.js @@ -1657,7 +1657,11 @@ export default class Core { return this.childMatcher.findChildGroup(childType, parentClass); } - findChildGroupNoAdapters(componentType, parentClass, afterAdapters = false) { + findChildGroupNoAdapters( + componentType, + parentClass, + afterAdapters = false, + ) { return this.childMatcher.findChildGroupNoAdapters( componentType, parentClass, @@ -7952,7 +7956,6 @@ export default class Core { return this.deletionEngine.deleteComponents(args); } - removeComponentsFromResolver(componentsToRemove) { return this.resolverAdapter.removeComponentsFromResolver( componentsToRemove, diff --git a/packages/doenetml-worker-javascript/src/ProcessQueue.ts b/packages/doenetml-worker-javascript/src/ProcessQueue.ts index 701ff0b9c..db246f646 100644 --- a/packages/doenetml-worker-javascript/src/ProcessQueue.ts +++ b/packages/doenetml-worker-javascript/src/ProcessQueue.ts @@ -58,10 +58,7 @@ export class ProcessQueue { let result; try { if (nextUpdateInfo.type === "update") { - if ( - !nextUpdateInfo.skippable || - this.queue.length < 2 - ) { + if (!nextUpdateInfo.skippable || this.queue.length < 2) { result = await this.core.performUpdate(nextUpdateInfo); } @@ -70,10 +67,7 @@ export class ProcessQueue { // } else if (nextUpdateInfo.type === "getStateVariableValues") { // result = await this.core.performGetStateVariableValues(nextUpdateInfo); } else if (nextUpdateInfo.type === "action") { - if ( - !nextUpdateInfo.skippable || - this.queue.length < 2 - ) { + if (!nextUpdateInfo.skippable || this.queue.length < 2) { result = await this.core.performAction(nextUpdateInfo); } diff --git a/packages/doenetml-worker-javascript/src/RendererInstructionBuilder.ts b/packages/doenetml-worker-javascript/src/RendererInstructionBuilder.ts index 6f89a5af9..461c08be3 100644 --- a/packages/doenetml-worker-javascript/src/RendererInstructionBuilder.ts +++ b/packages/doenetml-worker-javascript/src/RendererInstructionBuilder.ts @@ -130,7 +130,10 @@ export class RendererInstructionBuilder { this.componentsToRender[componentIdx].children; let previousChildIdentifiers: string[] = []; - for (let [ind, child] of previousChildRenderers.entries() as Iterable< + for (let [ + ind, + child, + ] of previousChildRenderers.entries() as Iterable< [number, any] >) { if (child === null) { @@ -158,13 +161,12 @@ export class RendererInstructionBuilder { // delete old renderers for (let child of previousChildRenderers) { if (child?.componentIdx != undefined) { - let deletedNames = this.deleteFromComponentsToRender( - { + let deletedNames = + this.deleteFromComponentsToRender({ componentIdx: child.componentIdx, recurseToChildren: true, componentsWithChangedChildrenToRenderInProgress, - }, - ); + }); deletedRenderers.push(...deletedNames); } } @@ -332,7 +334,10 @@ export class RendererInstructionBuilder { if (component.constructor.renderChildren) { let indicesToRender = await this.core.returnActiveChildrenIndicesToRender(component); - for (let [ind, child] of component.activeChildren.entries() as Iterable< + for (let [ + ind, + child, + ] of component.activeChildren.entries() as Iterable< [number, any] >) { if (indicesToRender.includes(ind)) { From f401a7fc728f569ed225873bf30f93b8abc243e0 Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Sun, 3 May 2026 02:14:45 -0500 Subject: [PATCH 6/7] refactor(worker-javascript): add ProcessQueue.reset() for symmetry Replaces the open-coded three-field reset in `Core.generateDast` with `processQueueManager.reset()`, matching the pattern used by `RendererInstructionBuilder` and `ActionTriggerScheduler`. Also updates `CORE_REFACTOR_DEFERRED.md` to extend the `core: any` typing item and the stateless-managers item with the Phase 2 managers (`RendererInstructionBuilder`, `ProcessQueue`, `ComponentLifecycle`, `ChildMatcher`, `DeletionEngine`, `ActionTriggerScheduler`). Co-Authored-By: Claude Opus 4.7 (1M context) --- CORE_REFACTOR_DEFERRED.md | 15 +++++++++------ packages/doenetml-worker-javascript/src/Core.js | 4 +--- .../src/ProcessQueue.ts | 10 ++++++++++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/CORE_REFACTOR_DEFERRED.md b/CORE_REFACTOR_DEFERRED.md index 0306d69b8..e63ebf1ba 100644 --- a/CORE_REFACTOR_DEFERRED.md +++ b/CORE_REFACTOR_DEFERRED.md @@ -1,14 +1,12 @@ # Core.js refactor — deferred findings -These items came out of the PR review for `core-refactor-1` (Phase 1: extracting helpers from `packages/doenetml-worker-javascript/src/Core.js`). They were intentionally out of scope for that PR but are good candidates for the next refactor pass. - -The implementation in this branch covers the in-scope items from the PR review. +These items came out of the PR reviews for the multi-phase refactor (`core-refactor-1`, `core-refactor-2`, …) extracting helpers from `packages/doenetml-worker-javascript/src/Core.js`. They were intentionally out of scope for those PRs but are good candidates for a follow-up pass. ## Deferred items ### Type the `core: any` back-reference in extracted managers -Every new manager (`DiagnosticsManager`, `VisibilityTracker`, `StatePersistence`, `AutoSubmitManager`, `NavigationHandler`, `ResolverAdapter`) declares `core: any;`. That defeats the point of converting to TypeScript — typos and accidental property reads through `core` go unchecked. +Every new manager declares `core: any;` — Phase 1 (`DiagnosticsManager`, `VisibilityTracker`, `StatePersistence`, `AutoSubmitManager`, `NavigationHandler`, `ResolverAdapter`) and Phase 2 (`RendererInstructionBuilder`, `ProcessQueue`, `ComponentLifecycle`, `ChildMatcher`, `DeletionEngine`, `ActionTriggerScheduler`). That defeats the point of converting to TypeScript — typos and accidental property reads through `core` go unchecked. Since `Core.js` is still JavaScript, defining a real `Core` interface is awkward. Two practical paths: @@ -19,7 +17,12 @@ Since `Core.js` is still JavaScript, defining a real `Core` interface is awkward ### Reduce stateless managers to plain functions -`NavigationHandler` and `ResolverAdapter` hold no state of their own — only a `core` back-reference. The pure-function shape used in `StateVariableNameResolver.ts` is more honest for these: +Several managers hold no state of their own — only a `core` back-reference: + +- Phase 1: `NavigationHandler`, `ResolverAdapter` +- Phase 2: `ComponentLifecycle`, `DeletionEngine` (and `ChildMatcher`, modulo its single recursion-guard array) + +The pure-function shape used in `StateVariableNameResolver.ts` is more honest for these: ```ts // NavigationHandler.ts @@ -30,7 +33,7 @@ export async function handleNavigatingToComponent({ export function navigateToTarget({ core, args }: { core: CoreBackref; args: any }) { ... } ``` -Core's wrappers shrink by one line each. If we keep the class form for symmetry with the other (genuinely stateful) managers, that's defensible — but the current PR has both shapes coexisting, so the inconsistency is real. +Core's wrappers shrink by one line each. If we keep the class form for symmetry with the other (genuinely stateful) managers, that's defensible — but the current shape has both forms coexisting, so the inconsistency is real. `ChildMatcher`'s `derivingChildResultsInProgress` array is a small enough piece of state that it could be lifted into a module-level closure or threaded through the call, allowing it to also become a plain-function module. ### Move `getSourceLocationForComponent` out of `DiagnosticsManager` diff --git a/packages/doenetml-worker-javascript/src/Core.js b/packages/doenetml-worker-javascript/src/Core.js index 569b9be61..6f88cf6fb 100644 --- a/packages/doenetml-worker-javascript/src/Core.js +++ b/packages/doenetml-worker-javascript/src/Core.js @@ -303,9 +303,7 @@ export default class Core { // Reset the process queue managed by `this.processQueueManager` // (see ProcessQueue.ts) so a previous run does not leak into this // document. - this.processQueueManager.queue = []; - this.processQueueManager.processing = false; - this.processQueueManager.stopProcessingRequests = false; + this.processQueueManager.reset(); this.dependencies = new DependencyHandler({ _components: this._components, diff --git a/packages/doenetml-worker-javascript/src/ProcessQueue.ts b/packages/doenetml-worker-javascript/src/ProcessQueue.ts index db246f646..cae1c12c1 100644 --- a/packages/doenetml-worker-javascript/src/ProcessQueue.ts +++ b/packages/doenetml-worker-javascript/src/ProcessQueue.ts @@ -48,6 +48,16 @@ export class ProcessQueue { this.stopProcessingRequests = false; } + /** + * Clear the queue and processing flags. Called from `Core.generateDast` + * so state from any previous run does not leak in. + */ + reset(): void { + this.queue = []; + this.processing = false; + this.stopProcessingRequests = false; + } + async executeProcesses(): Promise { if (this.stopProcessingRequests) { return; From 49f8fa1e05588230debbf3d28819be49b099694a Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Sun, 3 May 2026 03:02:10 -0500 Subject: [PATCH 7/7] refactor(worker-javascript): address Phase 2 review feedback Documentation and small cleanups in the Phase 2 managers, plus deferred items captured for follow-up: - RendererInstructionBuilder: rewrite the misleading "(slated for later phases)" docstring (deriveChildResultsFromDefiningChildren and returnActiveChildrenIndicesToRender are in ChildMatcher this PR) - All six new managers: add method-level docstrings on the non-obvious public methods; the top-of-module docs already covered ownership - ProcessQueue: extract `_kickoff()` (was duplicated 3x in the request entry points), and attach `.catch(console.error)` to its `executeProcesses()` invocation per AGENTS.md - ChildMatcher: convert `derivingChildResultsInProgress` from number[]-as-Set to a real `Set` - ComponentLifecycle: drop redundant `expandComposites: true` in the retry call (it's the default), drop `recursive === true` - DeletionEngine: rewrite the broken-numbering "1./3./4." comment as a proper two-phase docstring; rename `compositeIdxStr` to `compositeIdx` (it's a number, not a string) - All managers: standardize on `core._components` (was mixed with `core.components` getter, same array) Deferred to CORE_REFACTOR_DEFERRED.md: - Sweeping remaining fire-and-forget calls outside ProcessQueue - Carried-over TODO/XXX markers (line numbers updated for new files) - Renaming `processQueueManager` to `processQueue` (requires also dropping Core's `get/set processQueue` accessor that exposes the underlying array) - The `_components`/`components` access convention (now consistent in Phase 2; document so future managers follow suit) Co-Authored-By: Claude Opus 4.7 (1M context) --- CORE_REFACTOR_DEFERRED.md | 28 +++++++++ .../src/ActionTriggerScheduler.ts | 40 +++++++++++-- .../src/ChildMatcher.ts | 58 +++++++++++++++---- .../src/ComponentLifecycle.ts | 30 ++++++++-- .../src/DeletionEngine.ts | 37 ++++++++---- .../src/ProcessQueue.ts | 56 ++++++++++++++---- .../src/RendererInstructionBuilder.ts | 57 ++++++++++++++++-- 7 files changed, 258 insertions(+), 48 deletions(-) diff --git a/CORE_REFACTOR_DEFERRED.md b/CORE_REFACTOR_DEFERRED.md index e63ebf1ba..3984c81a6 100644 --- a/CORE_REFACTOR_DEFERRED.md +++ b/CORE_REFACTOR_DEFERRED.md @@ -73,6 +73,34 @@ Three intentionally unawaited Promise calls remain in `Core.js`. They pre-date t Suggested fix in each case: wrap with `.catch(reportTimerError("