From 2baaebcecf713ec4e37db94ea5dbd5f208e0cdcc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:47:18 +0000 Subject: [PATCH 1/5] Bound the prerender app's persistence block to the render route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `__boxelPrerenderApp` blocks persistence on every store in the prerender app. The render route raised it and nothing ever cleared it, so a pool tab that had served a card render kept the block for the rest of its life — including after `PagePool` retagged it from a realm affinity onto a user affinity for a command. Commands write, so such a command answered `status: "ready"` with a card that was never saved. Clear it in the render route's teardown so its lifetime tracks that route being entered (`beforeModel` raises it again ahead of every visit's model work), and clear it in the command route's `beforeModel` too: an in-app transition runs the departing route's exit hooks after the entering route's, so the command route cannot rely on the render route having torn down yet. Writes from a command stay deadlock-safe through `__boxelHeadlessCommand`, which indexes them deferred. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012JN9nUPZmHWKKL8KnwLmTs --- packages/host/app/routes/command-runner.ts | 8 + packages/host/app/routes/render.ts | 9 + .../prerender-persistence-block-test.gts | 192 ++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 packages/host/tests/acceptance/prerender-persistence-block-test.gts diff --git a/packages/host/app/routes/command-runner.ts b/packages/host/app/routes/command-runner.ts index abe986e6604..19dc5384c24 100644 --- a/packages/host/app/routes/command-runner.ts +++ b/packages/host/app/routes/command-runner.ts @@ -93,6 +93,14 @@ export default class CommandRunnerRoute extends Route { // tests also raise around in-browser index renders that run alongside an // interactive app whose saves must keep their indexed echo. (globalThis as any).__boxelHeadlessCommand = true; + // A command writes, so the prerender app's blanket persistence block must + // not be in force on this route. The pool can retag a tab that has served + // a card render onto a command affinity, and the render route is left by + // an in-app transition whose exit hooks run *after* this hook — so this + // route drops the flag itself rather than relying on the departing route's + // teardown to have already run. Writes from here stay deadlock-safe + // through `__boxelHeadlessCommand`, which indexes them deferred. + (globalThis as any).__boxelPrerenderApp = undefined; registerDestructor(this, () => { (globalThis as any).__boxelHeadlessCommand = undefined; if (isTesting()) { diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index e8b8dd8f7a9..9673a102c5a 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -199,6 +199,14 @@ export default class RenderRoute extends Route { (globalThis as any).__boxelRenderStageSetAt = undefined; (globalThis as any).__boxelRenderDiagnostics = undefined; (globalThis as any).__waitForRenderLoadStability = undefined; + // `__boxelPrerenderApp` lives exactly as long as this route is entered. + // `beforeModel` raises it again ahead of every visit's model work, so no + // render ever runs without the persistence block in force; bounding it + // here is what keeps a tab that leaves the render tree from carrying the + // block into work that must write. A prerender pool tab can be retagged + // from a realm affinity onto a command affinity, and a store still + // holding this flag discards every write it is handed. + (globalThis as any).__boxelPrerenderApp = undefined; this.#detachWindowErrorListeners(); this.lastStoreResetKey = undefined; this.renderBaseParams = undefined; @@ -986,6 +994,7 @@ export default class RenderRoute extends Route { (globalThis as any).__renderModel = undefined; (globalThis as any).__boxelRenderCapturedDeps = undefined; (globalThis as any).__docsInFlight = undefined; + (globalThis as any).__boxelPrerenderApp = undefined; (globalThis as any).__waitForRenderLoadStability = undefined; (globalThis as any).__boxelLastAttemptedRenderCardId = undefined; }); diff --git a/packages/host/tests/acceptance/prerender-persistence-block-test.gts b/packages/host/tests/acceptance/prerender-persistence-block-test.gts new file mode 100644 index 00000000000..17806cdc901 --- /dev/null +++ b/packages/host/tests/acceptance/prerender-persistence-block-test.gts @@ -0,0 +1,192 @@ +import { visit, waitFor } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { Command, type RenderRouteOptions } from '@cardstack/runtime-common'; + +import SaveCardTool from '@cardstack/host/tools/save-card'; + +import { + capturePrerenderResult, + setupLocalIndexing, + setupOnSave, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, +} from '../helpers'; + +import { + CardDef, + Component, + contains, + field, + setupBaseRealm, + StringField, +} from '../helpers/base-realm'; + +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupApplicationTest } from '../helpers/setup'; + +// `__boxelPrerenderApp` is the prerender app's blanket persistence block: while +// it is raised, every store in the app drops writes rather than aiming them at +// a realm whose sole indexing worker the render is occupying. The render route +// raises it, and its lifetime is bounded by that route being entered. +// +// A prerender pool tab can be retagged from a realm affinity onto a user +// affinity, and it enters the command route through an in-app transition — so +// the app carries across whatever globals the render left behind. Commands +// write, which makes that hand-off the case worth pinning: a command running +// under the block answers with a card that was never saved. +// +// Outside tests the render route raises the flag itself. Here it is raised by +// hand, because these tests also run an interactive app whose own saves the +// flag would swallow. +module('Acceptance | prerender | persistence block', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + + setupBaseRealm(hooks); + + const RENDER_OPTIONS_SEGMENT = encodeURIComponent( + JSON.stringify({ clearCache: true } as RenderRouteOptions), + ); + + async function renderCard(id: string) { + await visit( + `/render/${encodeURIComponent( + id, + )}/0/${RENDER_OPTIONS_SEGMENT}/html/isolated/0`, + ); + return await capturePrerenderResult('textContent'); + } + + function setCommandRunnerRequest( + requestId: string, + nonce: string, + command: string, + ) { + window.localStorage.setItem( + `boxel-command-request:${requestId}`, + JSON.stringify({ + command, + input: null, + nonce, + createdAt: Date.now(), + }), + ); + } + + function raisePersistenceBlock() { + (globalThis as any).__boxelPrerenderApp = true; + } + + hooks.beforeEach(async function () { + class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringField); + static isolated = class Isolated extends Component { + + }; + } + + class SavePetResult extends CardDef { + static displayName = 'SavePetResult'; + @field savedId = contains(StringField); + static isolated = class Isolated extends Component { + + }; + } + + // Reports the id its save came back with, so a dropped write is + // distinguishable from a durable one: an instance handed back from a + // blocked store carries no id, which reads as success to every caller. + class SavePetCommand extends Command { + static displayName = 'SavePetCommand'; + async getInputType() { + return undefined; + } + protected async run(): Promise { + let saved = await new SaveCardTool(this.toolContext).execute({ + card: new Pet({ name: 'Ringo' }), + realm: testRealmURL, + }); + return new SavePetResult({ savedId: saved?.id ?? '' }); + } + } + + await setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'pet.gts': { Pet }, + 'Pet/mango.json': new Pet({ name: 'Mango' }), + 'save-pet-command.gts': { + SavePetResult, + default: SavePetCommand, + }, + }, + }); + }); + + hooks.afterEach(function () { + delete (globalThis as any).__boxelPrerenderApp; + }); + + test('the block does not outlive the render route', async function (assert) { + raisePersistenceBlock(); + let { value } = await renderCard(`${testRealmURL}Pet/mango`); + assert.true(value.includes('Mango'), 'the card rendered'); + + await visit('/_standby'); + assert.strictEqual( + (globalThis as any).__boxelPrerenderApp, + undefined, + 'leaving the render route drops the persistence block', + ); + }); + + test('a command saves a durable card on a tab that has served a card render', async function (assert) { + raisePersistenceBlock(); + await renderCard(`${testRealmURL}Pet/mango`); + + let requestId = 'prerender-persistence-block-save'; + let nonce = '1'; + setCommandRunnerRequest( + requestId, + nonce, + `${testRealmURL}save-pet-command/default`, + ); + await visit(`/command-runner/${requestId}/${nonce}`); + await waitFor('[data-prerender][data-prerender-status="ready"]'); + + let savedId = + document.querySelector('[data-test-saved-pet-id]')?.textContent?.trim() ?? + ''; + assert.ok( + savedId.startsWith(testRealmURL), + `the save came back with a realm id: ${savedId || ''}`, + ); + + // Read the realm's own source rather than the store, so a card that only + // ever existed in memory cannot satisfy this. + let source = await getService('card-service').getSource( + new URL(`${savedId}.json`), + ); + assert.strictEqual(source.status, 200, 'the saved card is durable'); + assert.true( + source.content.includes('Ringo'), + 'the durable document holds what the command wrote', + ); + }); +}); From 31ba84bef70879b5dee7870bbb6fca2c79a82342 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:47:47 +0000 Subject: [PATCH 2/5] Error on a blocked store write that asked to persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `store.add()` returned the unpersisted instance when `renderContextBlocksPersistence()` was true. An instance with no id is indistinguishable from a saved one to every caller — `SaveCardTool` reports it as a success — so a blocked write surfaced as a wrong answer rather than a failure. Throw instead, unless the caller passed `doNotPersist` and asked for the in-memory instance. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012JN9nUPZmHWKKL8KnwLmTs --- packages/host/app/services/store.ts | 13 ++++++++++ .../host/tests/integration/store-test.gts | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 4b3cd99b53e..44d62c61a0f 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -838,6 +838,19 @@ export default class StoreService extends Service implements StoreInterface { await this.startAutoSaving(instance); if (this.renderContextBlocksPersistence()) { + // A caller that asked for persistence gets an error rather than the + // unpersisted instance. An instance handed back with no id is + // indistinguishable from a saved one — `SaveCardTool` reports it as a + // success — so returning it turns a blocked write into a wrong answer + // instead of a failure. Only `doNotPersist` callers wanted the + // in-memory instance, and they still get it. + if (!opts?.doNotPersist) { + throw new Error( + `cannot persist instance ${ + instance.id ?? instance[localIdSymbol] + }: persistence is blocked in this render context`, + ); + } return instance; } diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 00afd789a24..24696baeaad 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -607,6 +607,31 @@ module('Integration | Store', function (hooks) { } }); + test('adding an instance for persistence errors while persistence is blocked', async function (assert) { + // The prerender app raises `__boxelPrerenderApp` to block every store's + // writes. An instance handed back from that block carries no id, which is + // indistinguishable from a saved one to every caller — so a caller that + // asked for persistence gets an error instead, and only a `doNotPersist` + // caller gets the in-memory instance it asked for. + (globalThis as any).__boxelPrerenderApp = true; + try { + await assert.rejects( + storeService.add(new PersonDef({ name: 'Andrea' })), + /persistence is blocked/, + 'a persisting add reports the blocked write', + ); + + let instance = new PersonDef({ name: 'Mango' }); + assert.strictEqual( + await storeService.add(instance, { doNotPersist: true }), + instance, + 'a memory-only add is served as asked', + ); + } finally { + delete (globalThis as any).__boxelPrerenderApp; + } + }); + test('restoring sessions from storage skips the re-walk when the session blob is unchanged', function (assert) { // `restoreSessionsFromStorage` is synchronous, so the walk-count delta // measured immediately around each call is exactly that call's work — other From 3d9dfdeecb18c830923adf83b6a6ad680ce9ff94 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 14:55:05 +0000 Subject: [PATCH 3/5] Scope the blocked-write error to the prerender app, and drop the render-route teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render route's teardown clear went further than the defect needed. `deactivate()` fires when a transition leaves the `render` tree — Ember's `partitionRoutes` only exits a route when the route object itself changes, so a per-card dynamic-segment change never reaches it — and one of the routes it leaves for is `module`, on the same realm affinity within the same indexing job. `routes/module.ts` raises `__boxelRenderContext` but not `__boxelPrerenderApp`, and it renders through the render store, so clearing the flag on the way out leaves the regular `StoreService` unblocked for the whole module render while the tab still holds the worker. No write is reachable there today, but the invariant fails open instead of closed for the next route that forgets about it. `__boxelPrerenderApp` therefore keeps its app-lifetime scope, and the command route stays the single explicit carve-out. It drops rather than restores the flag: the render route raises it in `beforeModel`, which runs before the exit hooks of the route being left, so restoring a never-raised value would lower the block across the next render's model hook. Scope the `add()` error to the prerender app as well. `Store` reaches card authors through `@context.store`, and the render store is blocked during the interactive app's in-browser index renders too — where a card that writes while rendering has always had that write dropped rather than failing its render. Raise it before the instance is registered so a failed `add` leaves nothing resident. Pin the invariant the code now holds: entering the command route drops the block. Render against the card's `.json` URL, the shape the prerender driver uses, and guard the durability read on a non-empty id so a blocked save fails on its own assertion rather than on URL parsing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012JN9nUPZmHWKKL8KnwLmTs --- packages/host/app/routes/command-runner.ts | 24 ++++-- packages/host/app/routes/render.ts | 9 --- packages/host/app/services/store.ts | 44 +++++++---- .../prerender-persistence-block-test.gts | 78 ++++++++++--------- 4 files changed, 89 insertions(+), 66 deletions(-) diff --git a/packages/host/app/routes/command-runner.ts b/packages/host/app/routes/command-runner.ts index 19dc5384c24..2710623d853 100644 --- a/packages/host/app/routes/command-runner.ts +++ b/packages/host/app/routes/command-runner.ts @@ -93,13 +93,23 @@ export default class CommandRunnerRoute extends Route { // tests also raise around in-browser index renders that run alongside an // interactive app whose saves must keep their indexed echo. (globalThis as any).__boxelHeadlessCommand = true; - // A command writes, so the prerender app's blanket persistence block must - // not be in force on this route. The pool can retag a tab that has served - // a card render onto a command affinity, and the render route is left by - // an in-app transition whose exit hooks run *after* this hook — so this - // route drops the flag itself rather than relying on the departing route's - // teardown to have already run. Writes from here stay deadlock-safe - // through `__boxelHeadlessCommand`, which indexes them deferred. + // `__boxelPrerenderApp` marks the app as the dedicated prerender app for + // its whole lifetime, and blocks persistence on every store while it is + // raised (see `renderContextBlocksPersistence`). This route is the one + // exception: a command is expected to write, and its writes are + // deadlock-safe through the deferred indexing `__boxelHeadlessCommand` + // asks for. So drop the block here rather than leave it standing. + // + // It has to be dropped rather than merely never raised: a pool tab that + // has served a card render carries the flag, and the pool can retag that + // tab from its realm affinity onto the user affinity a command runs on. + // Under the block a command's save resolves to an instance with no id, + // which every caller reads as success. + // + // Deliberately not restored on teardown. The render route raises the flag + // itself in `beforeModel`, and that hook runs before the exit hooks of + // the route being left — so a restore of a never-raised value would lower + // the block for the whole of the next render's model hook. (globalThis as any).__boxelPrerenderApp = undefined; registerDestructor(this, () => { (globalThis as any).__boxelHeadlessCommand = undefined; diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index 9673a102c5a..e8b8dd8f7a9 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -199,14 +199,6 @@ export default class RenderRoute extends Route { (globalThis as any).__boxelRenderStageSetAt = undefined; (globalThis as any).__boxelRenderDiagnostics = undefined; (globalThis as any).__waitForRenderLoadStability = undefined; - // `__boxelPrerenderApp` lives exactly as long as this route is entered. - // `beforeModel` raises it again ahead of every visit's model work, so no - // render ever runs without the persistence block in force; bounding it - // here is what keeps a tab that leaves the render tree from carrying the - // block into work that must write. A prerender pool tab can be retagged - // from a realm affinity onto a command affinity, and a store still - // holding this flag discards every write it is handed. - (globalThis as any).__boxelPrerenderApp = undefined; this.#detachWindowErrorListeners(); this.lastStoreResetKey = undefined; this.renderBaseParams = undefined; @@ -994,7 +986,6 @@ export default class RenderRoute extends Route { (globalThis as any).__renderModel = undefined; (globalThis as any).__boxelRenderCapturedDeps = undefined; (globalThis as any).__docsInFlight = undefined; - (globalThis as any).__boxelPrerenderApp = undefined; (globalThis as any).__waitForRenderLoadStability = undefined; (globalThis as any).__boxelLastAttemptedRenderCardId = undefined; }); diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 44d62c61a0f..9e73f3348e6 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -339,6 +339,11 @@ export default class StoreService extends Service implements StoreInterface { // store on __boxelRenderContext alone breaks it: card-prerender sets that // global around every test-realm index render, silently dropping app saves // that coincide with one. + // + // The command-runner route is the one place in the prerender app that + // drops `__boxelPrerenderApp`, because a command is expected to write and + // its writes index deferred rather than waiting on the worker the tab is + // holding. if ((globalThis as any).__boxelPrerenderApp) { return true; } @@ -827,6 +832,28 @@ export default class StoreService extends Service implements StoreInterface { } as CardResourceMeta; } + // A caller that asked the prerender app to persist gets an error rather + // than the unpersisted instance. An instance handed back with no id is + // indistinguishable from a saved one — `SaveCardTool` reports it as a + // success — so returning it makes a blocked write a wrong answer instead + // of a failure, which is what lets a mis-marked tab answer a card-saving + // command with a card that does not exist. `create` already throws on the + // same state; `patch` reports a blocked write as `undefined`, which its + // callers already branch on. Thrown before the instance is registered so + // a failed `add` leaves nothing resident. + // + // Scoped to the prerender app rather than to every blocked context: host + // tests and the interactive app run in-browser index renders whose render + // store is blocked too, and a card that writes while rendering there has + // always had that write dropped rather than failing its render. + if (!opts?.doNotPersist && (globalThis as any).__boxelPrerenderApp) { + throw new Error( + `cannot persist instance ${ + instance.id ?? instance[localIdSymbol] + }: persistence is blocked in the prerender app`, + ); + } + let maybeOldInstance = instance.id ? this.store.getCard(instance.id) : undefined; @@ -838,19 +865,6 @@ export default class StoreService extends Service implements StoreInterface { await this.startAutoSaving(instance); if (this.renderContextBlocksPersistence()) { - // A caller that asked for persistence gets an error rather than the - // unpersisted instance. An instance handed back with no id is - // indistinguishable from a saved one — `SaveCardTool` reports it as a - // success — so returning it turns a blocked write into a wrong answer - // instead of a failure. Only `doNotPersist` callers wanted the - // in-memory instance, and they still get it. - if (!opts?.doNotPersist) { - throw new Error( - `cannot persist instance ${ - instance.id ?? instance[localIdSymbol] - }: persistence is blocked in this render context`, - ); - } return instance; } @@ -2067,6 +2081,10 @@ export default class StoreService extends Service implements StoreInterface { // deliberately not part of the test — card-prerender sets it around index // renders that run alongside an interactive app, whose own query fields must // keep resolving through those windows. + // + // A command runs with `__boxelPrerenderApp` dropped, so its query fields do + // resolve eagerly — matching what a command gets on a tab that has never + // served a render. protected resolvesQueryFieldsEagerly(): boolean { if ((globalThis as any).__boxelPrerenderApp) { return false; diff --git a/packages/host/tests/acceptance/prerender-persistence-block-test.gts b/packages/host/tests/acceptance/prerender-persistence-block-test.gts index 17806cdc901..01f34be4bf0 100644 --- a/packages/host/tests/acceptance/prerender-persistence-block-test.gts +++ b/packages/host/tests/acceptance/prerender-persistence-block-test.gts @@ -29,16 +29,18 @@ import { import { setupMockMatrix } from '../helpers/mock-matrix'; import { setupApplicationTest } from '../helpers/setup'; -// `__boxelPrerenderApp` is the prerender app's blanket persistence block: while -// it is raised, every store in the app drops writes rather than aiming them at -// a realm whose sole indexing worker the render is occupying. The render route -// raises it, and its lifetime is bounded by that route being entered. +// `__boxelPrerenderApp` marks the dedicated prerender app for its whole +// lifetime, and blocks persistence on every store in it: a write from a render +// would aim at a realm whose sole indexing worker that render is occupying. +// The command route is the one place that drops the block, because a command is +// expected to write and its writes index deferred. // -// A prerender pool tab can be retagged from a realm affinity onto a user -// affinity, and it enters the command route through an in-app transition — so -// the app carries across whatever globals the render left behind. Commands -// write, which makes that hand-off the case worth pinning: a command running -// under the block answers with a card that was never saved. +// Dropping it matters — rather than simply never raising it — because a pool +// tab that has served a card render carries the flag, and the pool can retag +// that tab from its realm affinity onto the user affinity commands run on. The +// tab then enters the command route through an in-app transition, so the app +// keeps whatever globals the render left behind, and a command running under +// the block answers with a card that was never saved. // // Outside tests the render route raises the flag itself. Here it is raised by // hand, because these tests also run an interactive app whose own saves the @@ -58,20 +60,18 @@ module('Acceptance | prerender | persistence block', function (hooks) { JSON.stringify({ clearCache: true } as RenderRouteOptions), ); + // The prerender driver always hands the render route the card's `.json` file + // URL, so render against that rather than the extensionless id. async function renderCard(id: string) { await visit( `/render/${encodeURIComponent( - id, + `${id}.json`, )}/0/${RENDER_OPTIONS_SEGMENT}/html/isolated/0`, ); return await capturePrerenderResult('textContent'); } - function setCommandRunnerRequest( - requestId: string, - nonce: string, - command: string, - ) { + function runCommand(requestId: string, nonce: string, command: string) { window.localStorage.setItem( `boxel-command-request:${requestId}`, JSON.stringify({ @@ -81,6 +81,7 @@ module('Acceptance | prerender | persistence block', function (hooks) { createdAt: Date.now(), }), ); + return visit(`/command-runner/${requestId}/${nonce}`); } function raisePersistenceBlock() { @@ -143,31 +144,31 @@ module('Acceptance | prerender | persistence block', function (hooks) { delete (globalThis as any).__boxelPrerenderApp; }); - test('the block does not outlive the render route', async function (assert) { + test('entering the command route drops the block', async function (assert) { raisePersistenceBlock(); - let { value } = await renderCard(`${testRealmURL}Pet/mango`); - assert.true(value.includes('Mango'), 'the card rendered'); + await runCommand( + 'prerender-persistence-block-drop', + '1', + `${testRealmURL}save-pet-command/default`, + ); - await visit('/_standby'); assert.strictEqual( (globalThis as any).__boxelPrerenderApp, undefined, - 'leaving the render route drops the persistence block', + 'the command route drops the persistence block on entry', ); }); test('a command saves a durable card on a tab that has served a card render', async function (assert) { raisePersistenceBlock(); - await renderCard(`${testRealmURL}Pet/mango`); + let { value } = await renderCard(`${testRealmURL}Pet/mango`); + assert.true(value.includes('Mango'), 'the card rendered'); - let requestId = 'prerender-persistence-block-save'; - let nonce = '1'; - setCommandRunnerRequest( - requestId, - nonce, + await runCommand( + 'prerender-persistence-block-save', + '1', `${testRealmURL}save-pet-command/default`, ); - await visit(`/command-runner/${requestId}/${nonce}`); await waitFor('[data-prerender][data-prerender-status="ready"]'); let savedId = @@ -177,16 +178,19 @@ module('Acceptance | prerender | persistence block', function (hooks) { savedId.startsWith(testRealmURL), `the save came back with a realm id: ${savedId || ''}`, ); - // Read the realm's own source rather than the store, so a card that only - // ever existed in memory cannot satisfy this. - let source = await getService('card-service').getSource( - new URL(`${savedId}.json`), - ); - assert.strictEqual(source.status, 200, 'the saved card is durable'); - assert.true( - source.content.includes('Ringo'), - 'the durable document holds what the command wrote', - ); + // ever existed in memory cannot satisfy this. Guarded on a non-empty id: + // a blocked save yields none, and the read would then fail on URL parsing + // instead of on the assertion above that explains why. + if (savedId) { + let source = await getService('card-service').getSource( + new URL(`${savedId}.json`), + ); + assert.strictEqual(source.status, 200, 'the saved card is durable'); + assert.true( + source.content.includes('Ringo'), + 'the durable document holds what the command wrote', + ); + } }); }); From 69e8570440f47932ac728ab8b87418eafe8f0832 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:13:30 +0000 Subject: [PATCH 4/5] Absorb a blocked write during a card render; report it only for a command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prerenderer is not an avenue for mutations, but a card whose template or computed writes to the store is doing what it was designed to do. Such a write is dropped, as it always has been, and the card renders — erroring would fail the render and index the card as an error instead. The error is reserved for the state that is genuinely impossible: a headless command running while the prerender app's persistence block is still raised. The command route drops the block on entry so a command's writes can land, so the block being up there means the drop did not happen, and the save resolves to an instance with no id that every caller reads as a saved card. The render route clears `__boxelHeadlessCommand` on entry so a render can never be taken for a command. A transition runs the entering route's model hooks before the departing route's exit hooks, so a tab arriving from the command route would otherwise carry that flag through the render's whole model hook — which also kept a render's writes from being marked for deferred indexing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012JN9nUPZmHWKKL8KnwLmTs --- packages/host/app/routes/render.ts | 9 +++++ packages/host/app/services/store.ts | 35 +++++++++++-------- .../host/tests/integration/store-test.gts | 35 ++++++++++++------- 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index e8b8dd8f7a9..09d627f2326 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -242,6 +242,15 @@ export default class RenderRoute extends Route { // activate() doesn't run early enough for this to be set before the model() // hook is run (globalThis as any).__boxelRenderContext = true; + // A render is never a headless command. The command route raises + // `__boxelHeadlessCommand` and drops it in its own teardown, but a + // transition runs the entering route's model hooks before the departing + // route's exit hooks — so a tab arriving here from the command route + // still carries the flag through this render's model hook. Clearing it + // keeps a render's writes from being marked for deferred indexing, and + // keeps a card that writes while rendering on the store's drop path + // rather than its report-a-stuck-command path. + (globalThis as any).__boxelHeadlessCommand = undefined; this.#registerGlobalsDestructor(); this.#authGuard.register(); if (!isTesting()) { diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 9e73f3348e6..a12bb8da574 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -833,24 +833,31 @@ export default class StoreService extends Service implements StoreInterface { } // A caller that asked the prerender app to persist gets an error rather - // than the unpersisted instance. An instance handed back with no id is - // indistinguishable from a saved one — `SaveCardTool` reports it as a - // success — so returning it makes a blocked write a wrong answer instead - // of a failure, which is what lets a mis-marked tab answer a card-saving - // command with a card that does not exist. `create` already throws on the - // same state; `patch` reports a blocked write as `undefined`, which its - // callers already branch on. Thrown before the instance is registered so - // a failed `add` leaves nothing resident. + // A headless command running while the prerender app's persistence block + // is still raised is an impossible state, and the only one this path + // reports rather than absorbs. The command route drops the block on entry + // precisely so a command's writes can land; with the block still up the + // save resolves to an instance carrying no id, `SaveCardTool` returns it + // as saved, and every caller downstream — `boxel run-command` included — + // reads a card that does not exist as a success. `create` already throws + // on the same state. Raised before the instance is registered, so a + // failed `add` leaves nothing resident. // - // Scoped to the prerender app rather than to every blocked context: host - // tests and the interactive app run in-browser index renders whose render - // store is blocked too, and a card that writes while rendering there has - // always had that write dropped rather than failing its render. - if (!opts?.doNotPersist && (globalThis as any).__boxelPrerenderApp) { + // A card render is deliberately NOT an error. The prerenderer is not an + // avenue for mutations, and a card whose template or computed writes to + // the store is doing what it was designed to do — it just cannot have + // that write here, because it would aim at a realm whose sole indexing + // worker this render is occupying. Dropping the write renders the card; + // throwing would fail the render and index the card as an error. + if ( + !opts?.doNotPersist && + (globalThis as any).__boxelPrerenderApp && + (globalThis as any).__boxelHeadlessCommand + ) { throw new Error( `cannot persist instance ${ instance.id ?? instance[localIdSymbol] - }: persistence is blocked in the prerender app`, + }: a headless command is running with the prerender app's persistence block still raised`, ); } diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 24696baeaad..8361a54a4ae 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -607,27 +607,38 @@ module('Integration | Store', function (hooks) { } }); - test('adding an instance for persistence errors while persistence is blocked', async function (assert) { - // The prerender app raises `__boxelPrerenderApp` to block every store's - // writes. An instance handed back from that block carries no id, which is - // indistinguishable from a saved one to every caller — so a caller that - // asked for persistence gets an error instead, and only a `doNotPersist` - // caller gets the in-memory instance it asked for. + test('a card render absorbs a blocked write, a headless command reports it', async function (assert) { + // `__boxelPrerenderApp` blocks every store's writes in the prerender app. + // A card render absorbs the block: the prerenderer is not an avenue for + // mutations, so a card that writes from a template or computed still + // renders, with the write dropped. (globalThis as any).__boxelPrerenderApp = true; try { + let rendered = new PersonDef({ name: 'Andrea' }); + assert.strictEqual( + await storeService.add(rendered), + rendered, + 'a render keeps the instance rather than failing', + ); + + // A command is the one caller whose write must land, so the block being + // up here means the command route did not drop it — and an instance + // with no id reads as a saved card to every caller. + (globalThis as any).__boxelHeadlessCommand = true; await assert.rejects( - storeService.add(new PersonDef({ name: 'Andrea' })), - /persistence is blocked/, - 'a persisting add reports the blocked write', + storeService.add(new PersonDef({ name: 'Van Gogh' })), + /persistence block still raised/, + 'a command reports the blocked write', ); - let instance = new PersonDef({ name: 'Mango' }); + let ephemeral = new PersonDef({ name: 'Mango' }); assert.strictEqual( - await storeService.add(instance, { doNotPersist: true }), - instance, + await storeService.add(ephemeral, { doNotPersist: true }), + ephemeral, 'a memory-only add is served as asked', ); } finally { + delete (globalThis as any).__boxelHeadlessCommand; delete (globalThis as any).__boxelPrerenderApp; } }); From 1022402f0c91d6bbba84b18498e060bc9e76f95b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 20:47:11 +0000 Subject: [PATCH 5/5] Say what the blocked-write check actually guarantees Drop an orphaned sentence fragment above the check, and the claim that raising it early leaves nothing resident. Both input shapes register before this point: a doc carrying an id is set into the store by card-api's createFromSerialized, and an instance has its deps setCard'd on the way in. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012JN9nUPZmHWKKL8KnwLmTs --- packages/host/app/services/store.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index a12bb8da574..c676dc5fa50 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -832,7 +832,6 @@ export default class StoreService extends Service implements StoreInterface { } as CardResourceMeta; } - // A caller that asked the prerender app to persist gets an error rather // A headless command running while the prerender app's persistence block // is still raised is an impossible state, and the only one this path // reports rather than absorbs. The command route drops the block on entry @@ -840,8 +839,7 @@ export default class StoreService extends Service implements StoreInterface { // save resolves to an instance carrying no id, `SaveCardTool` returns it // as saved, and every caller downstream — `boxel run-command` included — // reads a card that does not exist as a success. `create` already throws - // on the same state. Raised before the instance is registered, so a - // failed `add` leaves nothing resident. + // on the same state. // // A card render is deliberately NOT an error. The prerenderer is not an // avenue for mutations, and a card whose template or computed writes to