@kudzujs/core 0.8.61 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/MIGRATION_ROADMAP.md +37 -1
  2. package/PERFORMANCE.md +79 -1
  3. package/README.md +2 -2
  4. package/RELEASES.md +58 -0
  5. package/bin/kudzu.mjs +10 -1
  6. package/docs/next-architecture/0.9-baseline.md +1199 -0
  7. package/docs/next-architecture/0.9-benchmark-contracts.md +507 -0
  8. package/docs/next-architecture/0.9-component-property-contract.md +89 -0
  9. package/docs/next-architecture/0.9-compression-ledger.md +227 -0
  10. package/docs/next-architecture/0.9-final-proof-audit.md +176 -0
  11. package/docs/next-architecture/0.9-implementation-plan.md +1819 -0
  12. package/docs/next-architecture/0.9-resource-lifecycle.md +118 -0
  13. package/docs/next-architecture/0.9-semantic-compression.md +384 -0
  14. package/docs/next-architecture/README.md +16 -12
  15. package/docs/next-architecture/compiler-current-architecture.md +7 -7
  16. package/docs/next-architecture/large-application-ai-native-roadmap.md +6 -4
  17. package/docs/next-architecture/versioning.md +3 -2
  18. package/framework/README.md +3 -1
  19. package/framework/binding-runtime.js +4 -4
  20. package/framework/build.mjs +135 -30
  21. package/framework/compiler/ast-helpers.mjs +5 -0
  22. package/framework/compiler/browser-signal-passes.mjs +2 -7
  23. package/framework/compiler/collection-analysis.mjs +4 -0
  24. package/framework/compiler/descriptor-session.mjs +36 -12
  25. package/framework/compiler/effect-analysis.mjs +28 -8
  26. package/framework/compiler/effect-codegen.mjs +79 -36
  27. package/framework/compiler/effect-private-ref-pass.mjs +4 -8
  28. package/framework/compiler/handler-lowering.mjs +12 -7
  29. package/framework/compiler/ir/module-ir.mjs +26 -4
  30. package/framework/compiler/list-runtime-codegen.mjs +4 -2
  31. package/framework/compiler/optimize/command-specialization.mjs +4 -7
  32. package/framework/compiler/outside-click-pass.mjs +79 -0
  33. package/framework/compiler/react-migration-pass.mjs +27 -1
  34. package/framework/compiler/route-artifact-report.mjs +4 -3
  35. package/framework/compiler/route-build-record.mjs +12 -0
  36. package/framework/compiler/route-capability-planner.mjs +3 -3
  37. package/framework/compiler/route-ir.mjs +27 -11
  38. package/framework/compiler/runtime-codegen.mjs +2 -2
  39. package/framework/compiler/source-compiler.mjs +407 -74
  40. package/framework/core.d.ts +1 -0
  41. package/framework/core.mjs +18 -5
  42. package/framework/dependency-runtime.js +1 -1
  43. package/framework/effect-runtime.js +2 -2
  44. package/framework/list-runtime.js +67 -24
  45. package/framework/native-runtime.js +12 -9
  46. package/framework/runtime.js +1 -1
  47. package/framework/serialization.js +13 -6
  48. package/framework/shared-runtime.js +14 -12
  49. package/package.json +1 -1
@@ -0,0 +1,79 @@
1
+ import ts from "typescript"
2
+ import { isUnshadowedGlobal, unwrapExpression } from "./ast-helpers.mjs"
3
+
4
+ const valueName = "__kOutsideClickValue"
5
+
6
+ export function analyzeOutsideClickHook(hook) {
7
+ if (!hook || hook.asteriskToken || hook.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || !ts.isBlock(hook.body) || hook.body.statements.length !== 1) return undefined
8
+ if (!frameworkImport(hook.getSourceFile(), "useEffect")) return undefined
9
+ const normalized = hook.parameters.length === 3 && ts.isIdentifier(hook.parameters[2].name) && hook.parameters[2].name.text === valueName
10
+ if (!normalized && hook.parameters.length !== 2) return undefined
11
+ const [refParameter, callbackParameter] = hook.parameters
12
+ if (!ts.isIdentifier(refParameter?.name) || refParameter.initializer || refParameter.dotDotDotToken || !ts.isIdentifier(callbackParameter?.name) || callbackParameter.initializer || callbackParameter.dotDotDotToken) return undefined
13
+ const statement = hook.body.statements[0]
14
+ if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression) || !ts.isIdentifier(statement.expression.expression) || statement.expression.expression.text !== "useEffect" || statement.expression.arguments.length !== 2) return undefined
15
+ const [setup, dependencies] = statement.expression.arguments
16
+ if ((!ts.isArrowFunction(setup) && !ts.isFunctionExpression(setup)) || setup.parameters.length || setup.asteriskToken || setup.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || !ts.isBlock(setup.body) || setup.body.statements.length !== 3 || !ts.isArrayLiteralExpression(dependencies)) return undefined
17
+ const sourceDependencies = dependencies.elements.length === 2 && ts.isIdentifier(dependencies.elements[0]) && dependencies.elements[0].text === refParameter.name.text && ts.isIdentifier(dependencies.elements[1]) && dependencies.elements[1].text === callbackParameter.name.text
18
+ if (normalized ? dependencies.elements.length !== 0 : !sourceDependencies) return undefined
19
+ const [handlerStatement, addStatement, cleanupStatement] = setup.body.statements
20
+ if (!ts.isFunctionDeclaration(handlerStatement) || handlerStatement.asteriskToken || handlerStatement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || !handlerStatement.name || handlerStatement.parameters.length !== 1 || !ts.isIdentifier(handlerStatement.parameters[0].name) || !handlerStatement.body || handlerStatement.body.statements.length !== 1) return undefined
21
+ const event = handlerStatement.parameters[0].name.text
22
+ const condition = handlerStatement.body.statements[0]
23
+ if (!ts.isIfStatement(condition) || condition.elseStatement || !ts.isBlock(condition.thenStatement) || condition.thenStatement.statements.length !== 1 || !outsideCondition(condition.expression, refParameter.name.text, event)) return undefined
24
+ const callbackStatement = condition.thenStatement.statements[0]
25
+ if (!ts.isExpressionStatement(callbackStatement) || !ts.isCallExpression(callbackStatement.expression) || !ts.isIdentifier(callbackStatement.expression.expression) || callbackStatement.expression.expression.text !== callbackParameter.name.text) return undefined
26
+ if (normalized ? callbackStatement.expression.arguments.length !== 1 || !ts.isIdentifier(callbackStatement.expression.arguments[0]) || callbackStatement.expression.arguments[0].text !== valueName : callbackStatement.expression.arguments.length) return undefined
27
+ if (!listenerStatement(addStatement, "addEventListener", handlerStatement.name.text, hook.getSourceFile())) return undefined
28
+ const cleanup = ts.isReturnStatement(cleanupStatement) && cleanupStatement.expression && (ts.isArrowFunction(cleanupStatement.expression) || ts.isFunctionExpression(cleanupStatement.expression)) ? cleanupStatement.expression : undefined
29
+ const cleanupBody = cleanup && ts.isBlock(cleanup.body) && cleanup.body.statements.length === 1 ? cleanup.body.statements[0] : undefined
30
+ if (!cleanup || cleanup.parameters.length || cleanup.asteriskToken || cleanup.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || !listenerStatement(cleanupBody, "removeEventListener", handlerStatement.name.text, hook.getSourceFile())) return undefined
31
+ return { callbackCall: callbackStatement.expression, dependencies, normalized, valueName }
32
+ }
33
+
34
+ export function normalizeOutsideClickHooks(sourceFile, factory, context) {
35
+ const visitor = node => {
36
+ if (ts.isFunctionDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
37
+ const hook = analyzeOutsideClickHook(node)
38
+ if (hook && !hook.normalized) {
39
+ const rewrite = current => {
40
+ if (current === hook.callbackCall) return factory.updateCallExpression(current, current.expression, current.typeArguments, [factory.createIdentifier(valueName)])
41
+ if (current === hook.dependencies) return factory.updateArrayLiteralExpression(current, [])
42
+ return ts.visitEachChild(current, rewrite, context)
43
+ }
44
+ return updateFunction(node, [...node.parameters, factory.createParameterDeclaration(undefined, undefined, valueName)], ts.visitEachChild(node.body, rewrite, context), factory)
45
+ }
46
+ }
47
+ return ts.visitEachChild(node, visitor, context)
48
+ }
49
+ return ts.visitNode(sourceFile, visitor)
50
+ }
51
+
52
+ function updateFunction(node, parameters, body, factory) {
53
+ if (ts.isFunctionDeclaration(node)) return factory.updateFunctionDeclaration(node, node.modifiers, node.asteriskToken, node.name, node.typeParameters, parameters, node.type, body)
54
+ if (ts.isFunctionExpression(node)) return factory.updateFunctionExpression(node, node.modifiers, node.asteriskToken, node.name, node.typeParameters, parameters, node.type, body)
55
+ return factory.updateArrowFunction(node, node.modifiers, node.typeParameters, parameters, node.type, node.equalsGreaterThanToken, body)
56
+ }
57
+
58
+ function outsideCondition(node, ref, event) {
59
+ node = unwrapExpression(node)
60
+ if (!ts.isBinaryExpression(node) || node.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken || !currentAccess(unwrapExpression(node.left), ref)) return false
61
+ const right = unwrapExpression(node.right)
62
+ if (!ts.isPrefixUnaryExpression(right) || right.operator !== ts.SyntaxKind.ExclamationToken) return false
63
+ const contains = unwrapExpression(right.operand)
64
+ return ts.isCallExpression(contains) && contains.arguments.length === 1 && ts.isPropertyAccessExpression(contains.expression) && contains.expression.name.text === "contains" && currentAccess(unwrapExpression(contains.expression.expression), ref) && ts.isPropertyAccessExpression(unwrapExpression(contains.arguments[0])) && ts.isIdentifier(unwrapExpression(contains.arguments[0]).expression) && unwrapExpression(contains.arguments[0]).expression.text === event && unwrapExpression(contains.arguments[0]).name.text === "target"
65
+ }
66
+
67
+ function currentAccess(node, ref) {
68
+ return ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === ref && node.name.text === "current"
69
+ }
70
+
71
+ function listenerStatement(statement, method, handler, sourceFile) {
72
+ if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression) || statement.expression.arguments.length !== 2 || !ts.isStringLiteral(statement.expression.arguments[0]) || statement.expression.arguments[0].text !== "mousedown" || !ts.isIdentifier(statement.expression.arguments[1]) || statement.expression.arguments[1].text !== handler || !ts.isPropertyAccessExpression(statement.expression.expression)) return false
73
+ const target = statement.expression.expression.expression
74
+ return ts.isIdentifier(target) && target.text === "document" && isUnshadowedGlobal(target, sourceFile) && statement.expression.expression.name.text === method
75
+ }
76
+
77
+ function frameworkImport(sourceFile, name) {
78
+ return sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && !statement.importClause?.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier) && ["react", "@kudzujs/core"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.isTypeOnly && !entry.propertyName && entry.name.text === name))
79
+ }
@@ -5,7 +5,7 @@ import { analyzeCollectionPipeline, isArrayFromCall } from "./collection-analysi
5
5
  export function createReactMigrationPass({ cloneAst, jsxTagName }) {
6
6
  function normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections = new Set()) {
7
7
  const supported = new Set(["createContext", "useContext", "useEffect", "useId", "useReducer", "useRef", "useState"])
8
- const erased = new Set(["forwardRef", "memo", "useCallback", "useMemo"])
8
+ const erased = new Set(["createRef", "forwardRef", "memo", "useCallback", "useMemo"])
9
9
  const aliases = new Map()
10
10
  const reactObjects = new Set()
11
11
  for (const statement of sourceFile.statements) {
@@ -110,6 +110,15 @@ export function createReactMigrationPass({ cloneAst, jsxTagName }) {
110
110
  }
111
111
  if (ts.isCallExpression(node)) {
112
112
  const name = migrationCallName(node)
113
+ if (name === "createRef") {
114
+ const declaration = node.parent
115
+ const owner = ts.isVariableDeclaration(declaration) && nearestFunction(declaration)
116
+ if (!ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !ts.isIdentifier(declaration.name) || !isLocalConst(declaration) || !owner) throw sourceNodeError(node, sourceFile, "React createRef() must initialize one top-level const identifier in a component")
117
+ if (node.arguments.length) throw sourceNodeError(node, sourceFile, "React createRef() does not accept runtime arguments")
118
+ if (!hasOneIntrinsicRef(owner, declaration.name.text)) throw sourceNodeError(declaration, sourceFile, "React createRef() must be attached exactly once to an intrinsic element")
119
+ required.add("useRef")
120
+ return factory.createCallExpression(factory.createIdentifier("useRef"), node.typeArguments, [factory.createNull()])
121
+ }
113
122
  if (name === "forwardRef") return ts.visitNode(lowerReactForwardRef(node, sourceFile, factory), visitor)
114
123
  if (name === "memo") {
115
124
  if (node.arguments.length !== 1 || !(ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0]) || ts.isIdentifier(node.arguments[0]))) throw sourceNodeError(node, sourceFile, "React memo() requires exactly one function component or component identifier")
@@ -251,6 +260,23 @@ export function createReactMigrationPass({ cloneAst, jsxTagName }) {
251
260
  : factory.updateFunctionExpression(callback, callback.modifiers, undefined, callback.name, callback.typeParameters, [parameter], callback.type, callback.body)
252
261
  }
253
262
 
263
+ function hasOneIntrinsicRef(owner, name) {
264
+ let attachments = 0
265
+ let intrinsic = 0
266
+ const visit = node => {
267
+ if (node !== owner && isFunctionLike(node)) return
268
+ if (ts.isJsxAttribute(node) && node.name.text === "ref" && node.initializer && ts.isJsxExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === name) {
269
+ attachments++
270
+ const element = node.parent?.parent
271
+ const tag = ts.isJsxOpeningElement(element) || ts.isJsxSelfClosingElement(element) ? element.tagName : undefined
272
+ if (ts.isIdentifier(tag) && tag.text[0] === tag.text[0].toLowerCase()) intrinsic++
273
+ }
274
+ ts.forEachChild(node, visit)
275
+ }
276
+ visit(owner.body)
277
+ return attachments === 1 && intrinsic === 1
278
+ }
279
+
254
280
  function validateUseIdSyntax(sourceFile) {
255
281
  const imported = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && ["@kudzujs/core", "react"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useId"))
256
282
  if (!imported) return
@@ -18,7 +18,8 @@ export function createRouteArtifactReport(records, {
18
18
  if (!runtimeFamilies || !runtimeFamilyByRecord) ({ families: runtimeFamilies, familyByRecord: runtimeFamilyByRecord } = planRuntimeFamilies(records))
19
19
  const handlerGraph = handlerMetafile ? outputGraph(handlerMetafile, outputDirectory, base) : new Map()
20
20
  const routes = records.map(record => {
21
- const capability = planRouteCapabilities([record], { navigationRouteCount: Number(record.capabilities.navigable) })
21
+ const family = runtimeFamilyByRecord.get(record)
22
+ const capability = family && !family.navigation ? family.capability : planRouteCapabilities([record], { navigationRouteCount: Number(record.capabilities.navigable) })
22
23
  const handlerEntries = [...new Set(record.artifacts.handlers.map(reference => reference.module))].sort()
23
24
  const handlerOutputs = closure(handlerEntries, handlerGraph, Boolean(handlerMetafile))
24
25
  const workers = workerReferences
@@ -35,7 +36,7 @@ export function createRouteArtifactReport(records, {
35
36
  signature: capabilitySignature(capability),
36
37
  manifest: capability
37
38
  },
38
- runtime: routeRuntimeEdges(record, capability, runtimeFamilyByRecord.get(record), base, navigationAssets.get(record.route)),
39
+ runtime: routeRuntimeEdges(record, capability, family, base, navigationAssets.get(record.route)),
39
40
  handlers: {
40
41
  entries: handlerEntries,
41
42
  chunks: handlerOutputs.filter(output => !handlerEntries.includes(output))
@@ -58,7 +59,7 @@ export function createRouteArtifactReport(records, {
58
59
  id: family.id,
59
60
  signature: family.signature,
60
61
  navigation: family.navigation,
61
- routes: records.filter(record => runtimeFamilyByRecord.get(record)?.id === family.id).map(record => record.route).sort(),
62
+ routes: family.records.map(record => record.route).sort(),
62
63
  manifest: family.capability,
63
64
  requirements: familyRuntimeRequirements(family, base)
64
65
  })),
@@ -1,6 +1,7 @@
1
1
  import { assertJsonSafe, assertRouteIR } from "./route-ir.mjs"
2
2
 
3
3
  const validated = new WeakSet()
4
+ const releasedPlans = new WeakSet()
4
5
 
5
6
  export function createRouteBuildRecord(input) {
6
7
  const record = {
@@ -22,6 +23,10 @@ export function createRouteBuildRecord(input) {
22
23
  }
23
24
 
24
25
  export function assertRouteBuildRecord(record) {
26
+ if (releasedPlans.has(record)) {
27
+ if (record.plan !== undefined) throw new Error("Released RouteBuildRecord plan was restored")
28
+ return record
29
+ }
25
30
  if (validated.has(record)) return record
26
31
  if (record?.version !== 1) throw new Error(`Unsupported RouteBuildRecord version: ${JSON.stringify(record?.version)}`)
27
32
  if (typeof record.route !== "string" || typeof record.output !== "string" || typeof record.html !== "string" || !isRecord(record.plan)) throw new Error("Invalid RouteBuildRecord v1 structure")
@@ -60,6 +65,13 @@ export function assertRouteBuildRecord(record) {
60
65
  return record
61
66
  }
62
67
 
68
+ export function releaseRouteBuildRecordPlan(record) {
69
+ assertRouteBuildRecord(record)
70
+ record.plan = undefined
71
+ releasedPlans.add(record)
72
+ return record
73
+ }
74
+
63
75
  export function planRouteArtifacts(records, handlerModules, workerReferences, moduleUrl) {
64
76
  for (const record of records) assertRouteBuildRecord(record)
65
77
  const modules = new Map()
@@ -1,8 +1,8 @@
1
1
  import { assertRouteBuildRecord } from "./route-build-record.mjs"
2
2
  import { assertJsonSafe, assertRouteIR } from "./route-ir.mjs"
3
3
 
4
- export function usesRouteDependencyRuntime({ plan, navigable, hasBindings, hasLists }) {
5
- assertRouteIR(plan)
4
+ export function usesRouteDependencyRuntime({ plan, navigable, hasBindings, hasLists }, validate = true) {
5
+ if (validate) assertRouteIR(plan)
6
6
  const hasDependencies = plan.effects.some(effect => effect.dependencies?.length)
7
7
  return !navigable && hasDependencies && !plan.effects.some(effect => effect.owner) && !hasBindings && !hasLists && !plan.events.some(event => event.native)
8
8
  }
@@ -109,7 +109,7 @@ export function planRouteCapabilities(records, { navigationRouteCount = 0 } = {}
109
109
  dependency: routeEntries.some(route => route.usesDependencyRuntime)
110
110
  }
111
111
  }
112
- return validate ? assertCapabilityIR(capabilityIR, records, { navigationRouteCount }) : capabilityIR
112
+ return validate ? assertCapabilityIR(capabilityIR) : capabilityIR
113
113
  }
114
114
 
115
115
  export function assertCapabilityIR(capabilityIR, records, options = {}) {
@@ -1,5 +1,6 @@
1
1
  const markerFields = new Set(["cleanup", "list", "svg", "mount", "static", "indexed", "reducer", "nested", "effects", "conditions", "conditionHandlers", "textRanges", "attributes", "events", "expressions", "expressionAttributes", "fastRelease"])
2
2
  const validated = new WeakSet()
3
+ const jsonSafe = new WeakSet()
3
4
 
4
5
  export function assertRouteIR(plan, { concrete = false } = {}) {
5
6
  if (validated.has(plan) && (!concrete || typeof plan.route === "string")) return plan
@@ -62,7 +63,7 @@ export function assertRouteIR(plan, { concrete = false } = {}) {
62
63
  function assertEvent(event, index, ids) {
63
64
  if (!isRecord(event) || !nonempty(event.event) || event.commands === undefined && event.native === undefined) throw new Error(`Invalid RouteIR v1 event at index ${index}`)
64
65
  for (const command of event.commands ?? []) {
65
- if (!Array.isArray(command) || command.length !== 3 || !["set", "add", "log"].includes(command[0])) throw new Error(`RouteIR event ${index} command has unsupported operation ${JSON.stringify(command?.[0])}`)
66
+ if (!Array.isArray(command) || command.length !== 3 || !["set", "add", "toggle", "log"].includes(command[0])) throw new Error(`RouteIR event ${index} command has unsupported operation ${JSON.stringify(command?.[0])}`)
66
67
  if (!ids.has(command[1]) && !rowTemplate(command[1])) throw new Error(`RouteIR event ${index} command references missing state ${JSON.stringify(command[1])}`)
67
68
  if (command[0] === "add" && (typeof command[2] !== "number" || !Number.isFinite(command[2]))) throw new Error(`RouteIR event ${index} add command requires a finite number`)
68
69
  }
@@ -76,6 +77,13 @@ function assertEffect(effect, index, ids, lists) {
76
77
  if (new Set(effect.dependencies ?? []).size !== (effect.dependencies ?? []).length) throw new Error(`${label} has duplicate dependencies`)
77
78
  for (const state of Object.values(effect.dependencyStates ?? {})) if (!ids.has(state) && !rowTemplate(state)) throw new Error(`${label} derived dependency references missing state ${JSON.stringify(state)}`)
78
79
  if (effect.dependencyExpressions !== undefined && !Array.isArray(effect.dependencyExpressions) || effect.itemDependencies !== undefined && (!Array.isArray(effect.itemDependencies) || effect.itemDependencies.some(field => !nonempty(field)))) throw new Error(`${label} has invalid dependencies`)
80
+ if (effect.dependencyEvaluators !== undefined) {
81
+ if (!Array.isArray(effect.dependencyEvaluators) || !effect.dependencyEvaluators.length) throw new Error(`${label} has invalid calculation dependency evaluators`)
82
+ for (const [dependencyIndex, evaluator] of effect.dependencyEvaluators.entries()) {
83
+ assertReactiveDescriptor(evaluator, `${label} calculation dependency ${dependencyIndex}`, ids)
84
+ if (!nonempty(evaluator.field) || ["__proto__", "constructor", "prototype"].includes(evaluator.field)) throw new Error(`${label} calculation dependency ${dependencyIndex} has invalid field`)
85
+ }
86
+ }
79
87
  if (effect.itemDependencies?.length) {
80
88
  if (!nonempty(effect.listState) || !lists.some(list => list.state === effect.listState) || !effect.owner) throw new Error(`${label} item dependencies require a matching owned list`)
81
89
  }
@@ -165,30 +173,38 @@ function validSeed(seed) {
165
173
  }
166
174
 
167
175
  export function assertJsonSafe(value, label = "Value") {
168
- const invalid = invalidJsonPath(value, new Set(), "$")
176
+ const invalid = invalidJsonPath(value, new Set(), jsonSafe, [])
169
177
  if (invalid) throw new Error(`${label} is not JSON-safe at ${invalid}`)
170
178
  return value
171
179
  }
172
180
 
173
- function invalidJsonPath(value, seen, path) {
181
+ function invalidJsonPath(value, seen, safe, path) {
174
182
  if (value === null || typeof value === "string" || typeof value === "boolean") return undefined
175
- if (typeof value === "number") return Number.isFinite(value) && !Object.is(value, -0) ? undefined : path
176
- if (!value || typeof value !== "object" || seen.has(value) || Object.getOwnPropertySymbols(value).length) return path
183
+ if (typeof value === "number") return Number.isFinite(value) && !Object.is(value, -0) ? undefined : jsonPath(path)
184
+ if (!value || typeof value !== "object" || seen.has(value)) return jsonPath(path)
185
+ if (safe.has(value)) return undefined
177
186
  const prototype = Object.getPrototypeOf(value)
178
- if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return path
179
- const descriptors = Object.getOwnPropertyDescriptors(value)
180
- if (Array.isArray(value) && (Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key)) || Object.keys(value).length !== value.length)) return path
187
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return jsonPath(path)
188
+ const keys = Reflect.ownKeys(value)
189
+ if (keys.some(key => typeof key !== "string")) return jsonPath(path)
190
+ if (Array.isArray(value) && (keys.some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key)) || keys.length - 1 !== value.length)) return jsonPath(path)
181
191
  seen.add(value)
182
- for (const [key, descriptor] of Object.entries(descriptors)) {
192
+ for (const key of keys) {
183
193
  if (Array.isArray(value) && key === "length") continue
184
- if (!descriptor.enumerable || !("value" in descriptor)) return `${path}.${key}`
185
- const invalid = invalidJsonPath(descriptor.value, seen, `${path}.${key}`)
194
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
195
+ path.push(key)
196
+ if (!descriptor.enumerable || !("value" in descriptor)) return jsonPath(path)
197
+ const invalid = invalidJsonPath(descriptor.value, seen, safe, path)
186
198
  if (invalid) return invalid
199
+ path.pop()
187
200
  }
188
201
  seen.delete(value)
202
+ safe.add(value)
189
203
  return undefined
190
204
  }
191
205
 
206
+ const jsonPath = path => `$${path.map(key => `.${key}`).join("")}`
207
+
192
208
  const isRecord = value => value !== null && typeof value === "object" && !Array.isArray(value)
193
209
  const nonempty = value => typeof value === "string" && value.length > 0
194
210
  const rowTemplate = value => nonempty(value) && value.includes("$k")
@@ -7,8 +7,8 @@ export function generateCoreRuntime(source, capabilityIR) {
7
7
  if (!effects.itemDependencies && capabilityIR.runtime.shared) runtime = replaceRequired(runtime, /\/\* list-item-hooks \*\/[\s\S]*?\/\* list-item-hooks-end \*\/\n/, "", "list item hooks", "shared-runtime.js")
8
8
  if (effects.navigable) runtime = replaceRequired(runtime, "export function registerCommitter(commit) {\n committers.push(commit)\n}", "export function registerCommitter(commit) {\n committers.push(commit)\n return () => {\n const index = committers.indexOf(commit)\n if (index !== -1) committers.splice(index, 1)\n }\n}", "navigable committer", "shared-runtime.js")
9
9
  if (effects.navigableOwners) runtime = replaceSequenceRequired(runtime, [
10
- ["export function registerMountHook(mount) {\n mountHooks.push(mount)\n}", "export function registerMountHook(mount) {\n mountHooks.push(mount)\n return () => {\n const index = mountHooks.indexOf(mount)\n if (index !== -1) mountHooks.splice(index, 1)\n }\n}", "navigable mount hook"],
11
- ["export function registerUnmountHook(unmount) {\n unmountHooks.push(unmount)\n}", "export function registerUnmountHook(unmount) {\n unmountHooks.push(unmount)\n return () => {\n const index = unmountHooks.indexOf(unmount)\n if (index !== -1) unmountHooks.splice(index, 1)\n }\n}", "navigable unmount hook"]
10
+ ["export function registerMountHook(mount, capability) {\n mountHooks.push({ mount, capability })\n}", "export function registerMountHook(mount, capability) {\n const entry = { mount, capability }\n mountHooks.push(entry)\n return () => {\n const index = mountHooks.indexOf(entry)\n if (index !== -1) mountHooks.splice(index, 1)\n }\n}", "navigable mount hook"],
11
+ ["export function registerUnmountHook(unmount, capability) {\n unmountHooks.push({ unmount, capability })\n}", "export function registerUnmountHook(unmount, capability) {\n const entry = { unmount, capability }\n unmountHooks.push(entry)\n return () => {\n const index = unmountHooks.indexOf(entry)\n if (index !== -1) unmountHooks.splice(index, 1)\n }\n}", "navigable unmount hook"]
12
12
  ], "shared-runtime.js")
13
13
  return runtime
14
14
  }