Skip to content
Draft
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
164 changes: 164 additions & 0 deletions packages/matrix/tests/publish-realm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,170 @@ test.describe('Publish realm', () => {
).toBeVisible();
});

test('re-publishing reflects updated source content on the published URL (CS-11043)', async ({
page,
}) => {
// CS-11043 regression net. The bug was: a republish reported success
// server-side but the published URL kept serving the previous publish's
// rendered HTML, sometimes for tens of hours. Every existing
// publish-realm test does exactly one publish — this is the gap the
// bug slipped through. Here we publish, change content, publish
// again, and assert the published URL shows the new content (and not
// the old).

await clearLocalStorage(page, serverIndexUrl);
user = await createSubscribedUserAndLogin(
page,
'publish-realm',
serverIndexUrl,
);

let serverURL = new URL(serverIndexUrl);
let defaultRealmURL = `${serverURL.protocol}//${serverURL.host}/${user.username}/new-workspace/`;

await createRealm(page, 'new-workspace', '1New Workspace');

// Define a card type whose isolated template renders a single
// sentinel string we can grep for in the published HTML.
await postCardSource(
page,
defaultRealmURL,
'sentinel-card.gts',
`
import { CardDef, Component, field, contains } from "https://cardstack.com/base/card-api";
import StringField from "https://cardstack.com/base/string";

export class SentinelCard extends CardDef {
@field value = contains(StringField);

static isolated = class extends Component<typeof this> {
<template>
<div data-test-sentinel-output>{{@model.value}}</div>
</template>
};
}
`,
);

// Initial index.json: an instance of SentinelCard carrying the
// sentinel that we expect the first publish to render.
let initialSentinel = `sentinel-initial-${Date.now()}`;
await postCardSource(
page,
defaultRealmURL,
'index.json',
JSON.stringify(
{
data: {
type: 'card',
attributes: { value: initialSentinel },
meta: {
adoptsFrom: { module: './sentinel-card', name: 'SentinelCard' },
},
},
},
null,
2,
),
);

// Open the publish modal and do the first publish.
await page.locator('[data-test-workspace="1New Workspace"]').click();
await page.locator('[data-test-submode-switcher] button').click();
await page.locator('[data-test-boxel-menu-item-text="Host"]').click();
await page.locator('[data-test-publish-realm-button]').click();
await page.locator('[data-test-default-domain-checkbox]').click();
await page.locator('[data-test-publish-button]').click();
await page.waitForSelector('[data-test-unpublish-button]');

// Open the published URL and verify the initial sentinel renders.
let firstTabPromise = page.waitForEvent('popup');
await page
.locator(
'[data-test-publish-realm-modal] [data-test-open-boxel-space-button]',
)
.click();
let firstTab = await firstTabPromise;
await firstTab.waitForLoadState();
await expect(firstTab.locator('[data-test-sentinel-output]')).toHaveText(
initialSentinel,
{ timeout: 30_000 },
);
await firstTab.close();
await page.bringToFront();

// Close the modal so we can re-open it cleanly for the second publish.
await page.locator('[data-test-close-modal]').click();

// Change the index card's sentinel value. This is the "user edits
// their realm between publishes" step.
let updatedSentinel = `sentinel-updated-${Date.now()}`;
await postCardSource(
page,
defaultRealmURL,
'index.json',
JSON.stringify(
{
data: {
type: 'card',
attributes: { value: updatedSentinel },
meta: {
adoptsFrom: { module: './sentinel-card', name: 'SentinelCard' },
},
},
},
null,
2,
),
);

// Re-open the publish modal. The default-domain checkbox is still
// there (the realm appears as already-published in the modal); the
// publish button is what we re-click to push the new content.
await page.locator('[data-test-publish-realm-button]').click();
// The publish handler awaits sourceRealm.indexing() before doing the
// copy, so we don't need a manual settle here — the click below
// serializes behind any pending incremental indexing on the source.
await page.locator('[data-test-default-domain-checkbox]').click();
let publishButton = page.locator('[data-test-publish-button]');
// Set up the network wait BEFORE clicking — the handler awaits the
// full reindex before returning 202, so the response is the
// authoritative "publish is done" signal. 60s budget covers the
// from-scratch reindex even on slow CI.
let publishResponsePromise = page.waitForResponse(
(r) =>
r.url().endsWith('/_publish-realm') && r.request().method() === 'POST',
{ timeout: 60_000 },
);
await publishButton.click();
let publishResponse = await publishResponsePromise;
expect(
publishResponse.status(),
'second publish should succeed',
).toBeLessThan(300);

// Open the published URL again and verify the UPDATED sentinel
// renders — and the initial sentinel does NOT. This is the
// assertion CS-11043 would have failed: the old test only checked
// for "card visible", which stays true even when serving stale
// content.
let secondTabPromise = page.waitForEvent('popup');
await page
.locator(
'[data-test-publish-realm-modal] [data-test-open-boxel-space-button]',
)
.click();
let secondTab = await secondTabPromise;
await secondTab.waitForLoadState();
await expect(secondTab.locator('[data-test-sentinel-output]')).toHaveText(
updatedSentinel,
{ timeout: 30_000 },
);
await expect(secondTab.locator('body')).not.toContainText(initialSentinel);
await secondTab.close();
await page.bringToFront();
});

test('open site popover opens with shift-click', async ({ page }) => {
await publishDefaultRealm(page);

Expand Down
78 changes: 78 additions & 0 deletions packages/realm-server/tests/clear-cache-tracker-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { module, test } from 'qunit';
import { basename } from 'path';
import { ClearCacheTracker } from '@cardstack/runtime-common/index-runner/clear-cache-tracker';

// CS-11043. The IndexRunner used to track the "should the next render carry
// renderOptions.clearCache: true?" decision as a single boolean that the
// first render consumed and reset to false. That's correct as long as every
// render in the batch lands on the same puppeteer page — clearing the
// loader once primes the page for everything that follows. It's WRONG when
// the manager fans the batch out across multiple pages in the same affinity
// (PRERENDER_AFFINITY_TAB_MAX defaults to 5): only the first page sees
// clearCache, the rest keep stale module cache from earlier publishes of
// the same realm URL. The published nyuitp2026 realm rendered against an
// old presentation.gts for ~37 h after publishing for exactly this reason.
//
// The fix promotes the single boolean to a small state machine: default
// behavior (consume-once-then-stop) preserves the existing first-render
// arm, but when the IndexRunner detects an executable invalidation it
// upgrades the tracker to sticky-for-batch — every subsequent consume
// returns true so every fanned-out page gets a loader reset.

module(basename(__filename), function () {
module('ClearCacheTracker — consume-once mode (default)', function () {
test('first consume returns true, subsequent consumes return false', function (assert) {
let tracker = new ClearCacheTracker();
assert.strictEqual(tracker.consume(), true, 'first call returns true');
assert.strictEqual(tracker.consume(), false, 'second call returns false');
assert.strictEqual(tracker.consume(), false, 'third call returns false');
});

test('consume() returns false immediately when constructed off', function (assert) {
let tracker = new ClearCacheTracker({ initialMode: 'off' });
assert.strictEqual(tracker.consume(), false);
assert.strictEqual(tracker.consume(), false);
});
});

module('ClearCacheTracker — sticky-for-batch mode', function () {
test('upgradeToStickyForBatch on a fresh tracker: every consume returns true', function (assert) {
let tracker = new ClearCacheTracker();
tracker.upgradeToStickyForBatch();
for (let i = 0; i < 5; i++) {
assert.strictEqual(tracker.consume(), true, `consume #${i + 1}`);
}
});

test('upgrade after first consume rescues subsequent renders (the CS-11043 fan-out case)', function (assert) {
// Mirrors the live shape of an IndexRunner batch where
// executable invalidation is detected AFTER the first render
// has already been queued. Even in that ordering, every
// subsequent render needs clearCache to land on its own page.
let tracker = new ClearCacheTracker();
assert.strictEqual(tracker.consume(), true, 'first render gets clearCache');
assert.strictEqual(tracker.consume(), false, 'without upgrade, second would not');
tracker.upgradeToStickyForBatch();
assert.strictEqual(tracker.consume(), true, 'after upgrade, every consume returns true');
assert.strictEqual(tracker.consume(), true, 'and stays true');
});

test('upgrade is idempotent', function (assert) {
let tracker = new ClearCacheTracker();
tracker.upgradeToStickyForBatch();
tracker.upgradeToStickyForBatch();
assert.strictEqual(tracker.consume(), true);
assert.strictEqual(tracker.consume(), true);
});

test('an off tracker upgraded to sticky still flips on', function (assert) {
// Operationally we don't expect this combination today, but the
// contract should be unambiguous: the upgrade overrides off.
let tracker = new ClearCacheTracker({ initialMode: 'off' });
assert.strictEqual(tracker.consume(), false, 'off before upgrade');
tracker.upgradeToStickyForBatch();
assert.strictEqual(tracker.consume(), true, 'sticky overrides off');
assert.strictEqual(tracker.consume(), true);
});
});
});
1 change: 1 addition & 0 deletions packages/realm-server/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ import './billing-test';
import './card-dependencies-endpoint-test';
import './card-endpoints-test';
import './card-source-endpoints-test';
import './clear-cache-tracker-test';
import './definition-lookup-test';
import './file-watcher-events-test';
import './full-reindex-test';
Expand Down
44 changes: 30 additions & 14 deletions packages/runtime-common/index-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
type DiscoverInvalidationsResult,
} from './index-runner/discover-invalidations';
import { visitFileForIndexingFused } from './index-runner/visit-file';
import { ClearCacheTracker } from './index-runner/clear-cache-tracker';
import { performCardIndexing } from './index-runner/card-indexer';
import { performFileIndexing } from './index-runner/file-indexer';

Expand Down Expand Up @@ -83,7 +84,14 @@ export class IndexRunner {
fileErrors: 0,
totalIndexEntries: 0,
};
#shouldClearCacheForNextRender = true;
// CS-11043. Tracks whether the next prerender visit should carry
// `renderOptions.clearCache: true`. Initialized to consume-once so the
// first render after constructing a fresh IndexRunner primes its
// loader (matches the historical behavior). When the batch's
// invalidations include an executable file, we upgrade to
// sticky-for-batch so every fanned-out puppeteer page gets its own
// loader reset — see clear-cache-tracker.ts for the rationale.
#clearCacheTracker = new ClearCacheTracker();
// Identifier for this runner's indexing batch (CS-10758 step 3).
// Threaded into PrerenderVisitArgs and released from the fromScratch /
// incremental finally blocks. One runner = one batch: if fromScratch
Expand Down Expand Up @@ -201,6 +209,20 @@ export class IndexRunner {
await current.#dependencyResolver.orderInvalidationsByDependencies(
invalidations,
);
// CS-11043. Mirror the incremental path: when the batch's
// invalidations include an executable file, every render in the
// fan-out needs to land on a freshly-cleared loader, not just the
// first. Without this, multi-page affinities serve stale module
// bytes after a republish — see clear-cache-tracker.ts.
let hasExecutableInvalidation = invalidations.some((url) =>
hasExecutableExtension(url.href),
);
if (hasExecutableInvalidation) {
current.#log.debug(
`${jobIdentity(current.#jobInfo)} detected executable invalidation, upgrading loader-reset to sticky for the batch (CS-11043)`,
);
current.#upgradeClearCacheForBatch();
}
let resumedRows = current.batch.resumedRows;
let resumedSkipped = 0;
current.#onProgress?.({
Expand Down Expand Up @@ -344,12 +366,10 @@ export class IndexRunner {
hasExecutableExtension(url.href),
);
if (hasExecutableInvalidation) {
if (!current.#shouldClearCacheForNextRender) {
current.#log.debug(
`${jobIdentity(current.#jobInfo)} detected executable invalidation, scheduling loader reset`,
);
}
current.#scheduleClearCacheForNextRender();
current.#log.debug(
`${jobIdentity(current.#jobInfo)} detected executable invalidation, upgrading loader-reset to sticky for the batch (CS-11043)`,
);
current.#upgradeClearCacheForBatch();
}

let hrefs = urls.map((u) => u.href);
Expand Down Expand Up @@ -529,16 +549,12 @@ export class IndexRunner {
return this.#moduleCacheContext;
}

#scheduleClearCacheForNextRender() {
this.#shouldClearCacheForNextRender = true;
#upgradeClearCacheForBatch() {
this.#clearCacheTracker.upgradeToStickyForBatch();
}

#consumeClearCacheForRender(): boolean {
if (!this.#shouldClearCacheForNextRender) {
return false;
}
this.#shouldClearCacheForNextRender = false;
return true;
return this.#clearCacheTracker.consume();
}

@Memoize()
Expand Down
44 changes: 44 additions & 0 deletions packages/runtime-common/index-runner/clear-cache-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// CS-11043. Tracks whether the next prerender call from the indexer
// should carry `renderOptions.clearCache: true`. Extracted from
// IndexRunner so it can be unit-tested without spinning up the rest of
// the runner. See clear-cache-tracker-test.ts for the spec.
//
// Two modes:
// - 'consume-once': the next consume returns true, then it falls to
// 'off'. Matches the historical IndexRunner behavior of priming a
// single warm-tab loader at the start of a fresh run.
// - 'sticky-for-batch': every consume returns true. Used when the
// batch contains an executable invalidation (.gts/.ts/.js) — under
// PRERENDER_AFFINITY_TAB_MAX > 1 the batch's renders fan out across
// multiple puppeteer pages, so each page needs its own loader reset.
// - 'off': consume always returns false.
//
// `upgradeToStickyForBatch` is one-way: once the runner has decided the
// batch needs the sticky behavior, falling back to consume-once would
// silently leak stale module bytes into the next render.

export type ClearCacheTrackerMode = 'consume-once' | 'sticky-for-batch' | 'off';

export class ClearCacheTracker {
#mode: ClearCacheTrackerMode;

constructor(opts?: { initialMode?: ClearCacheTrackerMode }) {
this.#mode = opts?.initialMode ?? 'consume-once';
}

upgradeToStickyForBatch(): void {
this.#mode = 'sticky-for-batch';
}

consume(): boolean {
switch (this.#mode) {
case 'off':
return false;
case 'consume-once':
this.#mode = 'off';
return true;
case 'sticky-for-batch':
return true;
}
}
}
Loading