Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/implementation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ When a full page/template rebuild or targeted generated-pages rebuild no longer

### Dependency tracking

DOMStack uses [`@11ty/dependency-tree-typescript`](https://github.com/11ty/dependency-tree-typescript) to statically analyze ESM imports.
DOMStack uses [`@11ty/dependency-tree`](https://github.com/11ty/eleventy-utils/tree/main/parse-deps) to statically analyze JavaScript and TypeScript imports and re-exports.
The page watcher observes tracked imports within the source tree, including JSON files.
DOMStack maintains dependency maps for:

Expand Down
11 changes: 6 additions & 5 deletions lib/watch/dependency-analysis-watch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,14 +188,14 @@ function pauseObservation (t, filepath) {

describe('watch coordinator dependency analysis', { concurrency: false, timeout: 60_000 }, () => {
before(async () => {
// The installed CJS analyzer destructures readFileSync at module load. Install
// before importing DomStack, and count only its actual reads, not worker I/O,
// Install before importing DomStack, and count only the analyzer's actual
// reads, not worker I/O,
// module loading, fixture assertions, or supplied rebuild options.
const readFileSync = fs.readFileSync
/** @param {Parameters<typeof readFileSync>} args */
const probe = (...args) => {
if (typeof args[0] === 'string' && args[0].includes('.tmp-dependency-') &&
new Error().stack?.includes('@11ty/dependency-tree-esm/main.js')) {
new Error().stack?.includes('@11ty/dependency-tree/main.js')) {
analyzerReads.push(resolve(args[0]))
}
return readFileSync(...args)
Expand Down Expand Up @@ -490,7 +490,8 @@ describe('watch coordinator dependency analysis', { concurrency: false, timeout:
for (const kind of ['ignored', 'external', 'symlink']) {
await t.test(kind, async t => {
const helper = kind === 'external' ? '../outside.js' : kind === 'ignored' ? './ignored.js' : './alias.js'
const target = kind === 'external' ? '../outside.js' : kind === 'ignored' ? 'ignored.js' : 'target.js'
// The analyzer resolves symlinks; keep the target outside source observation.
const target = kind === 'external' ? '../outside.js' : kind === 'ignored' ? 'ignored.js' : '../target.js'
const site = await fixture(t, {
'root.layout.js': layout('root', helper),
[target]: "export const label = 'unobserved-initial'",
Expand All @@ -505,7 +506,7 @@ describe('watch coordinator dependency analysis', { concurrency: false, timeout:
await site.write('a/page.md', markdown('Markup ' + version))
await site.deliver('a/page.md')
assert.ok(site.reads().includes('root.layout.js'), kind + ' closure is analyzed on every successful pass')
assert.ok(site.reads().includes(helper.replace(/^\.\//, '')))
assert.ok(site.reads().includes(target.replace(/^\.\//, '')))
assert.ok(!site.reads().includes('other.layout.js'), 'fully observed unrelated closure still reuses analysis')
assert.match(await site.output(), new RegExp('unobserved-' + version + '.*Markup ' + version, 's'))
}
Expand Down
4 changes: 3 additions & 1 deletion lib/watch/dependency-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @typedef {(filepath: string) => boolean | Promise<boolean>} DependencyObservation
*/
import { resolve } from 'node:path'
import { find } from '@11ty/dependency-tree-typescript'
import { clearCache, find } from '@11ty/dependency-tree'
import { isProcessedFile, globalBundleAssets, pageBundleAssets, layoutBundleAssets } from '../file-conventions.js'

/** @type {DependencyObservation} */
Expand Down Expand Up @@ -158,6 +158,8 @@ export class WatchDependencyIndex {
* @param {{ reuseAnalysis?: boolean, isObserved?: DependencyObservation }} [options]
*/
async rebuild (siteData, { reuseAnalysis = false, isObserved = unobserved } = {}) {
// DOMStack's observation-aware cache owns reuse across rebuilds, not file stats.
clearCache()
const generation = ++this.#rebuildGeneration
const cwd = process.cwd()
if (!reuseAnalysis || this.#dependencyAnalysisFailed || cwd !== this.#analysisCwd || isObserved !== this.#observation) this.clearAnalysis()
Expand Down
65 changes: 63 additions & 2 deletions lib/watch/incremental-global-data-tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,69 @@ test('cached index adds, updates and removes distinct source IDs without rerende
await assert.rejects(stat(join(site.dest, 'a/index.html')), { code: 'ENOENT' })
})

test.todo('re-exported page helpers shared with templates upsert affected source pages (https://github.com/bcomnes/domstack/issues/328)')
test.todo('named, star, and namespace re-exports of global-data helpers trigger an index reset (https://github.com/bcomnes/domstack/issues/328)')
for (const extension of ['js', 'ts']) {
for (const kind of ['named', 'star', 'namespace']) {
/** @param {string} name @param {string} leaf */
const reexport = (name, leaf) => kind === 'namespace'
? `export * as helpers from './${leaf}.${extension}';`
: kind === 'star'
? `export * from './${leaf}.${extension}';`
: `export { ${name} } from './${leaf}.${extension}';`
/** @param {string} name @param {string} barrel */
const importValue = (name, barrel) => kind === 'namespace'
? `import { helpers } from '${barrel}.${extension}'; const { ${name} } = helpers;`
: `import { ${name} } from '${barrel}.${extension}';`

test(`re-exported page helpers shared with templates upsert affected source pages (${extension}, ${kind})`, options, async t => {
const site = await fixture(t, {
files: {
'root.layout.js': 'export default ({ children }) => children\n',
'code/page.js': `${importValue('content', '../page-middle')} export const vars = { article: true, title: 'Code' }; export default () => content\n`,
[`page-middle.${extension}`]: reexport('content', 'page-leaf'),
[`page-leaf.${extension}`]: "export const content = 'Code one'\n",
'shared.txt.template.js': `import { content } from './page-leaf.${extension}'; export default () => content\n`,
},
})
await site.start()
await site.write(`page-leaf.${extension}`, "export const content = 'Code two'\n")
const call = await site.rebuild([site.event(`page-leaf.${extension}`)])
assertDelta(call, ['code/page.js'])
assert.deepEqual(call.rendered, ['code/page.js'])
assert.equal(await readFile(join(site.dest, 'shared.txt'), 'utf8'), 'Code two')
assert.match(await readFile(join(site.dest, 'code/index.html'), 'utf8'), /Code two/)
const watched = await site.data()
assert.ok(watched.some(row => row.html.includes('Code two')))
await site.dom.stopWatching()
await site.dom.build()
assert.deepEqual(await site.data(), watched, 'watch output matches a fresh build')
})

test(`re-exported global-data helpers shared with templates reset the index (${extension}, ${kind})`, options, async t => {
const site = await fixture(t, {
files: {
'root.layout.js': 'export default ({ children }) => children\n',
'producer-middle.js': `${importValue('prefix', './producer-barrel')} export { prefix };\n`,
[`producer-barrel.${extension}`]: reexport('prefix', 'shared-producer-leaf'),
[`shared-producer-leaf.${extension}`]: "export const prefix = 'Original prefix: '\n",
'shared.txt.template.js': `import { prefix } from './shared-producer-leaf.${extension}'; export default () => prefix\n`,
},
})
await site.start()
assert.ok((await site.data()).every(row => row.html.startsWith('Original prefix: ')))
await site.write(`shared-producer-leaf.${extension}`, "export const prefix = 'Updated prefix: '\n")
const call = await site.rebuild([site.event(`shared-producer-leaf.${extension}`)])
assertReset(call)
assert.equal(call.reason, 'global-data-changed')
assert.deepEqual(call.rendered, ['a/page.md', 'b/page.md'])
assert.equal(await readFile(join(site.dest, 'shared.txt'), 'utf8'), 'Updated prefix: ')
const watched = await site.data()
assert.ok(watched.every(row => row.html.startsWith('Updated prefix: ')))
await site.dom.stopWatching()
await site.dom.build()
assert.deepEqual(await site.data(), watched, 'watch output matches a fresh build')
})
}
}

test('a shared dependency upserts a transitive source page even when a template imports it directly', options, async t => {
const site = await fixture(t, {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"node": "^22.18.0 || >=24.0.0"
},
"dependencies": {
"@11ty/dependency-tree-typescript": "^1.0.0",
"@11ty/dependency-tree": "5.0.0-alpha.3",
"@domstack/sync": "^0.0.8",
"argsclopts": "^1.0.4",
"async-folder-walker": "^3.0.5",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
declare module '@11ty/dependency-tree-typescript' {
declare module '@11ty/dependency-tree' {
export function clearCache (filePath?: string): void
export function find (filePath: string): Promise<string[]>
export function findGraph (filePath: string): Promise<object>
export function mergeGraphs (...graphs: object[]): object
Expand Down
Loading