Skip to content

Add declarative global-data subscriptions - #294

Merged
bcomnes merged 14 commits into
bret/nested-layoutsfrom
fix/v12-watch-dependency-tracking
Sep 7, 2026
Merged

Add declarative global-data subscriptions#294
bcomnes merged 14 commits into
bret/nested-layoutsfrom
fix/v12-watch-dependency-tracking

Conversation

@bcomnes

@bcomnes bcomnes commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Summary

  • Make global.data.* the only public hook that receives source-backed PageData[].
  • Let pages and layouts declare top-level global-data keys through vars.dataDeps.
  • Let templates and *.pages.* factories declare keys through a named dataDeps export.
  • Pass subscribed values through a separate data argument instead of merging them into vars.
  • Keep focused consumer contracts beside global data and check readonly subscription declarations with DataDeps<Contract>.
  • Express independent factory, inline-page, and layout data contracts, plus typed source-page input to global data.
  • Fingerprint top-level global-data values and rebuild only subscribers of changed keys in watch mode.
  • Execute only directly selected or invalidated generated-page factories, reserving untouched owners' output paths to prevent collisions.
  • Track imported global-data helpers and preserve domain subscription errors across worker transport.
  • Build on Add explicit nested layout chains #311’s explicit nested-layout chain and preserve generated-output ownership fixes.

Stack

Base: #311 (bret/nested-layouts), which introduces explicit nested layouts and resolves #290. This PR adds global-data subscriptions on top. The separate watch-maintenance stack is unchanged.

Design

This replaces the runtime property-observation design with an explicit data boundary.

global.data.* owns all source-page collection work and returns named values such as recentPosts, blogIndexes, or feedItems. Ordinary pages, layouts, templates, and page factories do not receive raw page collections. They declare the top-level values they need and receive only those values through data.

Pages declare dependencies in frontmatter, adjacent page vars, or a TypeScript page's vars export. Layouts declare dependencies in their vars export. Each renderer receives only its own declared data. DOMStack unions page declarations with every layout in the resolved parentLayout chain for output invalidation, then removes dataDeps from ordinary vars. Ancestor subscriptions are inherited for invalidation without being repeated by children or leaking into another renderer’s data. While global data is resolving, renderInnerPage() remains available for pages without their own subscriptions, even when their layouts subscribe. renderFullPage() requires the whole chain to be unsubscribed at that stage. The TypeScript examples export focused contracts such as FeedsTemplateData from global.data.ts, so consumers do not reconstruct selections from the complete GlobalData type. DataDeps<Contract> supports readonly declarations and checks names against the local consumer contract. PagesFunction<PageVars, Content, FactoryVars, FactoryData, PageData> separates a factory's input from its inline pages' subscriptions. GlobalDataFunction<Result, SourceVars, SourceContent> types the producer's source-page input without downstream casts.

Manual function composition is supported and tested, including subscribed data, generated pages, assets, and imported-parent watch updates. A composing layout declares the keys needed by the functions it calls and forwards their arguments. The README and migration guide recommend parentLayout so DOMStack can manage the full ancestor dependency chain automatically.

Templates and *.pages.* modules use a named export because they do not have consumer vars:

export const dataDeps = ['feedItems']

export default function ({ data }) {
  return JSON.stringify(data.feedItems)
}

Watch state now contains only consumer subscriptions and fingerprints for top-level global-data values. There is no AsyncLocalStorage, render-context attribution, page-property graph, or observational tracking proxy. The small proxy around data is only an API guard that reports reads of existing but undeclared keys.

JSON-safe values receive stable fingerprints. Opaque or cyclic values conservatively invalidate their subscribers on each page build rather than risking stale output. The fingerprint checks cover sparse arrays with extra properties, accessors, negative zero, and prototype-like key names without executing getters. Changes to statically imported global-data helpers recompute data while also selecting any direct consumers of the same helper, including modules that also serve as browser entry points. Untouched generated-page owner conflicts use source-relative names consistently with other output claims. The previous successful watch state remains authoritative when a build fails. The next page build retries the complete page phase, then resumes incremental routing. Subscription failures use DomStackDataError (DOM_STACK_ERROR_DATA) with a reason, consumer, and optional key; the subtype and metadata survive the worker boundary. Declaration validation and projection live in data-deps.js, separate from watch-state bookkeeping.

v12 prerelease API changes

  • Global data is no longer merged into vars.
  • Page functions, layouts, templates, and generated-page factories no longer receive pages.
  • Generated-page factories no longer receive siteData.
  • PageData.renderInnerPage() and PageData.renderFullPage() no longer take a pages argument.
  • Page and layout callback types accept a trailing generic for the declared data shape.
  • Templates accept a data generic and either synchronous or async results; AsyncTemplateFunction explicitly requires a promise.
  • Factories accept separate data generics for the factory and its inline pages, and can return no definitions.

Validation

  • Repository Node test suite, including declarative invalidation and targeted factory execution.
  • Nested-layout tests for isolated projections, ancestor invalidation, generated descendants, reparenting, and rendering inner content while global data resolves.
  • Generated-page and progressive-watch suites with polling enabled because native filesystem watches were exhausted on the local host.
  • Manual-composition regressions for forwarded data, source and generated subscribers, and unchanged unrelated outputs.
  • Imported-global-data helper and shared-browser-entry regressions, untouched-owner collision diagnostics, failed-build recovery, and conservative fingerprint regressions.
  • Worker round-trip tests for invalid declarations, missing keys, undeclared reads, and circular data access across consumer types.
  • Positive and negative compile-time tests for readonly keys, independent data contracts, heterogeneous layouts, sync/async callbacks, and typed producer input.
  • Published declaration generation checked during type validation, then cleaned with the repository scripts.
  • ESLint.
  • TypeScript.
  • Installed dependency validation.
  • Playwright Chromium cascade-layer test.
  • Blog example type-check and build.
  • Repository example builds.

Review context

The planning and release-review notes are preserved in the PR discussion rather than tracked source files. The README and v12 migration guide remain the user-facing documentation.

Follow-ups and limitations

Fixes #289.

@coveralls

coveralls commented Sep 6, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34160465524

Coverage increased (+0.02%) to 95.329%

Details

  • Coverage increased (+0.02%) from the base build.
  • Patch coverage: 4 uncovered changes across 2 files (829 of 833 lines covered, 99.52%).
  • 4 coverage regressions across 2 files.

Uncovered Changes

File Changed Covered %
index.js 38 36 94.74%
lib/build-pages/watch-dependencies.js 210 208 99.05%
Total (22 files) 833 829 99.52%

Coverage Regressions

4 previously-covered lines in 2 files lost coverage.

File Lines Losing Coverage Coverage
index.js 2 90.85%
lib/build-pages/page-builders/page-writer.js 2 97.64%

Coverage Stats

Coverage Status
Relevant Lines: 7909
Covered Lines: 7724
Line Coverage: 97.66%
Relevant Branches: 2153
Covered Branches: 1868
Branch Coverage: 86.76%
Branches in Coverage %: Yes
Coverage Strength: 276.65 hits per line

💛 - Coveralls

Comment thread lib/build-pages/page-builders/page-writer.js Outdated
Comment thread lib/build-pages/index.js Outdated
@bcomnes

bcomnes commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

This is really complex and ugly code. Does it make sense to have layout data or page data instead and then we just rebuild layouts and pages based on that relation.

@bcomnes bcomnes changed the title Track output dependencies in watch builds Add declarative global-data subscriptions Sep 7, 2026
@bcomnes

bcomnes commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Reworked in 915501d around that boundary. Only global.data receives the source PageData collection now. Pages and layouts declare top-level keys in vars.dataDependencies, templates and page factories use a named dataDependencies export, and every consumer receives only those values through a separate data argument. This removes AsyncLocalStorage, render-context attribution, page-property observation, and the page-collection dependency graph. Targeted builds now fingerprint top-level global-data keys and execute only subscribed pages, templates, and generated-page owners. The resolved-layout and obsolete-output fixes remain intact.

Comment thread lib/build-pages/page-data.js Outdated
@bcomnes
bcomnes force-pushed the fix/v12-watch-dependency-tracking branch from 3a74b7f to 65881c6 Compare September 7, 2026 18:51
@bcomnes
bcomnes changed the base branch from master to bret/nested-layouts September 7, 2026 18:51
@bcomnes
bcomnes force-pushed the fix/v12-watch-dependency-tracking branch from 1141daf to 2eec023 Compare September 7, 2026 19:14
@bcomnes
bcomnes requested a lite review from Copilot September 7, 2026 19:19
@bcomnes
bcomnes force-pushed the fix/v12-watch-dependency-tracking branch from 2eec023 to 36ffe87 Compare September 7, 2026 19:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A few small but concrete issues remain in changed code (redundant/incorrect type fixtures and an inconsistency that can leak absolute paths in conflict errors).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR introduces a declarative global-data subscription boundary so only global.data.* receives source-backed PageData[], while pages/layouts/templates/factories explicitly declare top-level keys they need and receive them via a separate data argument.
It also adds watch-mode fingerprinting for top-level global-data values so incremental rebuilds target only subscribers of changed keys, plus preserves data-subscription errors across the worker boundary.

Changes:

  • Add dataDeps declaration + data parameter plumbing across page/layout/template/generated-page factory execution and types.
  • Track subscriptions + fingerprints in watch mode and invalidate only subscribed consumers for changed global-data keys.
  • Add DomStackDataError (DOM_STACK_ERROR_DATA) and serialize/restore domain error metadata across worker transport.
File summaries
File Description
types.ts Re-exports new public types (DataDeps, AsyncTemplateFunction) to support subscription typing.
test-cases/watch/index.test.js Adds/updates watch-mode tests for data subscriptions, invalidation, collisions, and failure recovery.
test-cases/type-exports/index.test.ts Updates type-export fixtures to use data instead of pages.
test-cases/type-exports/data-deps.test.ts Adds compile-time tests for DataDeps and independent producer/consumer data contracts.
test-cases/nested-layouts/types.test.ts Updates nested-layout type fixtures for data generics and new render helper signatures.
test-cases/nested-layouts/index.test.js Adds runtime tests for nested-layout subscriptions, projection isolation, and watch invalidation behavior.
test-cases/generated-pages/src/summary.template.js Converts template to declarative dataDeps + data consumption.
test-cases/generated-pages/src/root.layout.js Declares layout dataDeps and reads subscribed data via data.
test-cases/generated-pages/src/redirects.pages.js Converts generated-pages factory to named dataDeps and data input.
test-cases/generated-pages/src/indexes.pages.js Converts generated-pages factory to named dataDeps and data input.
test-cases/generated-pages/src/concrete-only.pages.js Removes raw page-collection introspection from factory; uses subscribed data instead.
test-cases/generated-pages/src/blog-index.layout.js Declares layout dataDeps and updates layout typing for subscribed data.
test-cases/generated-pages/index.test.js Updates generated-pages tests for subscription model and adds worker error round-trip assertions.
test-cases/general-features/src/templates/single-object.template.js Updates template typing to async template type export.
test-cases/general-features/src/templates/simple.txt.template.js Updates template typing to async template type export.
test-cases/general-features/src/templates/object-array.template.js Updates template typing to async template type export.
test-cases/general-features/src/README.md Demonstrates frontmatter dataDeps and data.* template usage.
test-cases/general-features/src/global.data.js Expands global-data outputs (years/feed items) and shifts collection work into global data.
test-cases/general-features/src/feeds.template.js Converts feed template to named dataDeps and consumes prepared feed records.
test-cases/general-features/src/blog/page.js Converts blog index page to subscribed data input and adds dataDeps.
README.md Documents new subscription boundary, updated signatures/types, watch behavior, and migration guidance.
plans/v12-release-review.md Adds/updates release-review notes to reflect resolved issues and new subscription model.
plans/generated-pages.md Notes that earlier “pages/vars stamping” behavior is superseded by subscriptions.
lib/helpers/domstack-error.js Introduces DomStackDataError with structured dataDependency metadata.
lib/build-pages/worker.js Ensures worker reports serialized build errors instead of throwing them raw.
lib/build-pages/watch-dependencies.test.js Adds unit tests for subscription extraction, projection, fingerprinting, and invalidation.
lib/build-pages/watch-dependencies.js Implements subscription tracking + top-level fingerprint comparisons for watch invalidation.
lib/build-pages/resolve-vars.js Updates global-data resolution typing and comments for subscription model.
lib/build-pages/page-data.test.js Updates PageData tests for vars layering changes and new data-deps/data behavior.
lib/build-pages/page-data.js Implements page/layout subscription extraction, data projection, and render helper signature changes.
lib/build-pages/page-builders/template-builder.test.js Updates template builder test harness for new builder inputs.
lib/build-pages/page-builders/template-builder.js Adds template dataDeps support and passes subscribed data to templates.
lib/build-pages/page-builders/page-writer.js Removes pages plumbing; relies on PageData’s internal state and subscribed data.
lib/build-pages/index.js Threads subscriptions/fingerprints through build pipeline; adds error serialization; targets invalidated subscribers.
lib/build-pages/data-deps.js Adds runtime declaration validation, extraction, and projection guard proxy for subscribed data.
index.js Integrates watch dependency state into incremental rebuild routing and global-data helper import tracking.
examples/uhtml-isomorphic/src/layouts/root.layout.js Updates layout signature comment for data instead of pages.
examples/preact-isomorphic/src/layouts/root.layout.ts Updates example layout props to accept data instead of pages.
examples/blog/src/redirects.pages.ts Converts example generated-pages factory to dataDeps + subscribed data.
examples/blog/src/README.md Converts example markdown to frontmatter dataDeps + data.* usage.
examples/blog/src/layouts/root.layout.ts Removes global-data type merge from vars type to match new boundary.
examples/blog/src/global.data.ts Adds explicit source input typing and exposes focused consumer contracts for subscriptions.
examples/blog/src/feeds.template.ts Converts example feed template to subscribe to prepared feed items via data.
examples/blog/src/blog/page.ts Converts example blog index to subscribe to blog collections via data.
examples/blog/src/blog/2024/hello-world/README.md Updates docs to describe subscription model instead of global data stamped into vars.
examples/blog/src/blog-indexes.pages.ts Converts example year-index generator to dataDeps + subscribed data.
docs/v12-migration.md Updates migration guide for subscriptions, removed pages plumbing, and error behavior.
Review details

Suppressed comments (1)

test-cases/general-features/src/blog/page.js:8

  • The page is annotated as returning string, but it returns a fragtml HtmlResult; this forces a @ts-ignore below and undermines the type test fixture.
/**
 * @type {AsyncPageFunction<{}, string, { blogYears: string[] }>}
 */
  • Files reviewed: 47/47 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/build-pages/index.js
Comment thread lib/build-pages/page-data.test.js Outdated
Comment thread test-cases/general-features/src/blog/page.js
@bcomnes
bcomnes force-pushed the fix/v12-watch-dependency-tracking branch from 36ffe87 to dd0a049 Compare September 7, 2026 19:54
@bcomnes
bcomnes force-pushed the fix/v12-watch-dependency-tracking branch 2 times, most recently from b198428 to b380ff6 Compare September 7, 2026 20:21
@bcomnes

bcomnes commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Planning and release-review context

Moved the new release-review document and generated-pages plan note out of this PR's source changes. These notes preserve the review history; statements about the published beta, validation, and remaining findings describe the review snapshot, not a fresh release-readiness assessment.

Related issues

Generated-pages plan note

The existing plans/generated-pages.md records the original generated-pages implementation review from #253. Its descriptions of passing pages broadly or merging global.data.* into vars are superseded by this PR's declarative subscription boundary. The README and v12 migration guide describe the current public API. The older plan is left unchanged relative to this PR's base.

Full release-review notes, relocated from plans/v12-release-review.md

DOMStack v12 release review

This document records the issues found while reviewing 12.0.0-beta.2 before the stable v12 release.

Release blockers

Resolved: watch builds can leave collection consumers stale

A targeted source-page rebuild recalculates global.data, but it only writes the directly affected page. Other pages, templates, and generated pages may consume values derived from the changed page and remain stale.

This was reproduced with two Markdown pages and a global.data.js value derived from all page titles. After changing one title, that page received the new collection value while the other page retained the old value.

The prerelease API allowed these dependency paths:

  • A rendered page may consume values returned by global.data.
  • A template may consume source pages, generated pages, or global data.
  • A generated-pages factory may consume source pages or global data.
  • A layout may inspect the complete page collection.
  • readMarkdownContent(), renderInnerPage(), and renderFullPage() create content dependencies between outputs.

The resolved API removes raw page collections from ordinary consumers and routes intentional collection processing through global.data.*.

Blanket rebuilding every consumer would be correct but would defeat the purpose of granular rebuilds. The branch now resolves this through the explicit subscription model described in the "Declarative global-data dependencies" section below.

Resolved: layout watch mapping ignores builder vars and Markdown frontmatter

At the time of the review, the watcher's page-to-layout map resolved only default, global, and page.vars.* values. Actual page initialization also resolves builder variables, including Markdown frontmatter.

This was reproduced with a Markdown page that selects layout: blog through frontmatter. Changing blog.layout.js did not rebuild that page, and its output remained byte-for-byte unchanged.

The nested-layout prerequisite now reports each page's fully resolved layoutNames chain and persists those successful selections for watch routing. This covers frontmatter, builder vars, ancestors, and generated pages without independently approximating layout selection.

Published declarations do not pass strict consumer validation

The published 12.0.0-beta.2 tarball was installed into a clean TypeScript consumer using NodeNext resolution and skipLibCheck: false. Its declarations failed under TypeScript 5.9 and TypeScript 6.0.

The DOMStack-owned declaration errors are emitted for htmlBuilder and mdBuilder. Their return types reference an undeclared generic named T.

The public declaration graph also exposes declaration errors from cpx2. TypeScript 5.9 additionally reports a Markdown declaration incompatibility.

The repository's skipLibCheck: true setting masks these failures. Release validation should pack the package, install it in a clean fixture, and type-check that fixture with skipLibCheck: false.

Other findings

Offline examples extend a nonexistent TypeScript configuration

Both new offline examples extend ../tsconfig.json, but there is no examples/tsconfig.json. They should extend ../../tsconfig.json, matching the other examples.

The example build exits successfully while esbuild reports the missing configuration as a warning. This means the examples currently build without their intended shared compiler settings.

Affected files:

  • examples/static-mpa-offline/tsconfig.json
  • examples/static-mpa-workbox-offline/tsconfig.json

Production dependency audit reports a high-severity advisory

npm audit --omit=dev reports deepmerge-ts <8.0.0 through the direct write-package dependency. write-package is used only by the eject command.

The advisory concerns stack exhaustion while merging recursive object graphs, so exposure through parsed package.json data appears limited. There is no automatic npm fix. Replacing write-package or implementing the small package update directly would remove the known advisory from the production dependency graph.

Declarative global-data dependencies

The original experimental implementation inferred output dependencies by wrapping page collections and vars in read-tracking proxies, then used async-local context to attribute reads during concurrent rendering. That was mechanically capable but preserved the wrong public boundary: every consumer still received the complete page graph and therefore remained a potential collection consumer.

The replacement design makes collection processing an explicit phase:

  • Only global.data.* receives source-backed PageData[].
  • global.data.* returns named top-level values for downstream use.
  • Pages and layouts declare required keys in vars.dataDeps.
  • Templates and *.pages.* factories declare required keys with a named dataDeps export.
  • Consumers receive those values through a separate data argument rather than the ordinary variable cascade.
  • Raw source or generated page collections are not passed to pages, layouts, templates, or generated-page factories.

The worker fingerprints every top-level global-data value during watch page builds. It compares those fingerprints with the previous successful watch state and invalidates only consumers subscribed to keys whose values changed. Subscriptions are explicit records keyed by source page, generated output, template, or pages-file owner, so no async attribution or property-read graph is required.

Output dependencies are the union of declarations from frontmatter or page vars and every layout in the resolved parentLayout chain. Each renderer receives only its own projection of that data. The dataDeps metadata is removed before ordinary vars are exposed to rendering code. Generated-page subscriptions retain their pages-file owner so changed factory data can rebuild the owner and reconcile obsolete outputs.

This model intentionally tracks at top-level global-data key granularity. A consumer of blogPosts rebuilds when any part of that value changes, which is coarse enough to be dependable and narrow enough for the site-wide consumers that need collection data. JSON-safe values receive stable fingerprints, while opaque or cyclic values conservatively invalidate their subscribers on every page build. File imports remain covered by the existing static dependency maps, while untracked network, environment, or other external state must still cause its own source change or a broader rebuild.

Manual composition remains supported and tested, including subscribed data and statically imported parents. Explicit parentLayout nesting is recommended because DOMStack can manage ancestor defaults, assets, subscriptions, and rebuilds automatically. Shared static helpers invalidate all matching consumer categories, including the global-data producer. Targeted generated-page builds reserve untouched owners' output paths before rendering. Subscription errors preserve their domain subtype and metadata across worker transport; after a failure, the next page build retries the full page phase.

Validation completed during review

  • The repository was clean and synchronized with origin/master.
  • The current HEAD GitHub test workflow was green.
  • npm test passed the installed dependency check, ESLint, Node tests, Playwright, and TypeScript.
  • npm run build passed.
  • npm run build-examples passed with the two TypeScript configuration warnings described above.
  • The actual published 12.0.0-beta.2 tarball was inspected and runtime-imported successfully.
  • Clean tarball consumers were checked with TypeScript 5.9 and TypeScript 6.0.
  • The production dependency audit was run.
  • Both watch correctness bugs were independently reproduced.

@bcomnes
bcomnes force-pushed the fix/v12-watch-dependency-tracking branch from 1ff541a to e8e9e85 Compare September 7, 2026 20:40
@bcomnes
bcomnes merged commit fde933d into master Sep 7, 2026
10 checks passed
@bcomnes
bcomnes deleted the fix/v12-watch-dependency-tracking branch September 7, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Watch mode misses layouts selected through frontmatter or builder vars Track output-level dependencies for correct granular watch rebuilds

3 participants