Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
96a9a93
feat(fetch): record outgoing fetch duration by external host
Aug 4, 2026
d24f22a
fix(fetch): use hostname instead of host for server.address
Aug 4, 2026
972ebe3
fix(fetch): fold empty hostname into null in hostOf
Aug 4, 2026
bc1912f
feat(preview): sandbox-less Fast Preview via pull-based draft decofil…
guitavano Aug 12, 2026
d141fe5
Update version to 1.204.0
github-actions[bot] Aug 12, 2026
6ceaa9c
feat(preview): draft preview badge (#1223)
guitavano Aug 13, 2026
4664bfc
Update version to 1.204.1
github-actions[bot] Aug 13, 2026
7260ce5
Merge pull request #1218 from deco-cx/feat/outgoing-fetch-duration-me…
Aug 17, 2026
4ecf4b9
Update version to 1.205.0
github-actions[bot] Aug 17, 2026
659bbdf
fix(preview): carry draft pointer through lazy /deco/render (iframe) …
guitavano Aug 19, 2026
d56b1b1
feat(preview): infer <site>.deco.site as an allowed draft preview hos…
guitavano Aug 19, 2026
8d45119
Update version to 1.206.0
github-actions[bot] Aug 19, 2026
8c61d13
fix(device): prioritize deviceHint query param over cf-device-type (#…
guitavano Aug 19, 2026
48a4324
Update version to 1.206.1
github-actions[bot] Aug 19, 2026
2bf0281
feat(preview): infer envs-<site>--<hash>.decocdn.com as an allowed dr…
guitavano Aug 20, 2026
f5a30cf
Update version to 1.207.0
github-actions[bot] Aug 20, 2026
8731e6b
fix(engine): don't let an aborted read poison the shared resolvable m…
guitavano Aug 21, 2026
79d4f73
Update version to 1.208.0
github-actions[bot] Aug 21, 2026
7e1f241
fix(engine): bypass shared memo for aborted callers (don't poison sib…
guitavano Aug 21, 2026
bf53329
1.209.0
guitavano Aug 21, 2026
e6e8f51
fix(engine): don't inherit stale resolve hints on release swap (#1232)
guitavano Aug 28, 2026
4e145ce
Update version to 1.210.0
github-actions[bot] Aug 28, 2026
a497514
fix(ci): drop invalid `--reload=file:` from deno cache step (#1233)
guitavano Aug 28, 2026
2c2066a
Update version to 1.211.0
github-actions[bot] Aug 28, 2026
4a8b908
fix(engine): dispose superseded ReleaseResolver's release subscriptio…
Sep 21, 2026
6e37b77
Update version to 1.211.1
github-actions[bot] Sep 21, 2026
180dd68
feat(draft): allow fast preview on local dev hosts and the dev tunnel…
guitavano Sep 22, 2026
7dbb79a
Update version to 1.211.2
github-actions[bot] Sep 22, 2026
b94f65c
Merge remote-tracking branch 'upstream/main' into chore/sync-upstream…
marcoferreiradev Sep 30, 2026
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 .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
run: deno run -A --lock=deno.lock --frozen=false --reload mod.ts
- name: fetch any uncached dependencies
run: |
deno cache --lock=deno.lock --frozen=false --reload=file: ./mod.ts
deno cache --lock=deno.lock --frozen=false ./mod.ts

- run: |
deno test --lock=deno.lock --frozen=false -A .
Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@deco/deco",
"version": "1.203.0",
"version": "1.211.2",
"lock": false,
"nodeModulesDir": "auto",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion dev/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@deco/dev",
"version": "1.203.0",
"version": "1.211.2",
"exports": {
"./tailwind": "./tailwind.ts"
},
Expand Down
235 changes: 235 additions & 0 deletions engine/core/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,145 @@ import {
resolve,
type ResolverMap,
} from "../../engine/core/resolver.ts";
import { ReleaseResolver } from "../../engine/core/mod.ts";
import { fromEndpoint, fromJSON } from "../../engine/decofile/fetcher.ts";
import defaults from "../manifest/fresh.ts";
import { RequestContext } from "../../deco.ts";

Deno.test(".with({ release }) does not inherit stale resolve hints", async () => {
// Hints are cached by resolveType (block id) and derived from the release's
// resolvables. Fast Preview binds a request-scoped draft via
// `resolver.with({ release: draftProvider })`. If `.with` inherits the base
// resolver's hints, a block whose SHAPE changed between the published release
// and the draft (same id, different content) resolves against the stale shape
// and silently drops everything the old hints don't cover — the exact failure
// that blanked a page whose `sections` went from a plain array (published) to
// a `multivariate` flag (draft).
const resolvers = {
passthrough: (props: unknown) => props,
} as unknown as ResolverMap;

// Published: block "page" has a plain `content` (no nested resolvable).
const published = fromJSON({
page: { __resolveType: "passthrough", content: "plain" },
});
// Draft: SAME id "page", but `content` is now a nested resolvable — the draft
// needs a hint at `content` that the published shape never produced.
const draft = fromJSON({
page: { __resolveType: "passthrough", content: { __resolveType: "inner" } },
inner: { __resolveType: "passthrough", value: "from-draft" },
});

const base = new ReleaseResolver<BaseContext>({
release: published,
resolvers,
});

// Resolve against the published release first — this populates (poisons) the
// base resolver's hint cache for "page" with the plain-content shape.
assertEquals(await base.resolve<{ content: unknown }>("page", {}), {
content: "plain",
});

// Swap the release for the draft, as Fast Preview does.
const drafted = base.with({ release: draft });
assertEquals(await drafted.resolve<{ content: unknown }>("page", {}), {
content: { value: "from-draft" },
});
});

Deno.test("dispose() unsubscribes the resolver from its release", () => {
// `installApps` rebuilds the resolver on every decofile change:
// currentResolver = currentResolver.with({ resolvers, resolvables })
// Each `new ReleaseResolver` subscribes to the SAME provider. Without
// disposing the superseded resolver, its subscription keeps it — and its
// whole resolvables/resolvers/resolveHints graph — reachable from the
// provider's listener list forever, so every publish permanently retains one
// more resolver. This test pins the subscription count instead of the bytes.
const listeners = new Set<() => unknown>();
const release = {
state: () => Promise.resolve({}),
revision: () => Promise.resolve("1"),
onChange: (cb: () => unknown) => {
listeners.add(cb);
return { [Symbol.dispose]: () => listeners.delete(cb) };
},
// deno-lint-ignore no-explicit-any
} as any;

const base = new ReleaseResolver<BaseContext>({ release, resolvers: {} });
assertEquals(listeners.size, 1);

// Simulate repeated app installs, disposing each superseded resolver.
let current = base;
for (let i = 0; i < 25; i++) {
const superseded = current as unknown as { dispose?: () => void };
current = current.with({ resolvers: {} });
// Cast keeps this test meaningful without the fix: `dispose` is simply
// absent, the subscriptions pile up and the assertion below reports the
// real leak (one per rebuild) instead of failing to type-check.
superseded.dispose?.();
}
assertEquals(
listeners.size,
1,
"each publish must leave exactly one live subscription, not accumulate one per rebuild",
);

// Disposing is idempotent and actually detaches.
const last = current as unknown as { dispose?: () => void };
last.dispose?.();
last.dispose?.();
assertEquals(listeners.size, 0);
});

Deno.test("dispose() unsubscribes through an endpoint-backed provider", async () => {
// The fake release above implements `onChange` correctly. `fromEndpoint` —
// which every real deployment goes through, since `getProvider` wraps
// `folder://`, `file://`, `deconfig://` and `http(s)://` in it — used to
// discard the inner Disposable and hand back a no-op one, so `dispose()`
// unsubscribed nothing in production while the test above stayed green.
// Count live subscriptions through the real provider instead of a stand-in.
const server = Deno.serve(
{ port: 0, onListen: () => {} },
() => new Response(JSON.stringify({})),
);
// Each live subscription dispatches exactly one `deco:hmr` per notify, so the
// event count is the subscription count.
let hmr = 0;
const countHmr = () => hmr++;
addEventListener("deco:hmr", countHmr);
try {
const release = fromEndpoint(
`http://localhost:${server.addr.port}/decofile.json`,
);
await release.state();

let current = new ReleaseResolver<BaseContext>({ release, resolvers: {} });
for (let i = 0; i < 25; i++) {
const superseded = current;
current = current.with({ resolvers: {} });
superseded.dispose();
}
await release.notify?.();
assertEquals(
hmr,
1,
"each publish must leave exactly one live subscription on the endpoint-backed provider",
);

// Disposal that lands before the provider promise settles must still
// prevent the subscription from ever being registered.
hmr = 0;
current.dispose();
current.dispose();
await release.notify?.();
assertEquals(hmr, 0, "dispose() must detach the last resolver too");
} finally {
removeEventListener("deco:hmr", countHmr);
await server.shutdown();
}
});

Deno.test("resolve", async (t) => {
const context: BaseContext = {
Expand Down Expand Up @@ -342,3 +480,100 @@ Deno.test("resolve", async (t) => {
assertEquals(result, { foo: "hello", bar: { value: 10 } });
});
});

Deno.test("aborted reads must not poison the shared memo", async (t) => {
// A loader-like resolver that fails when the active RequestContext signal is
// aborted (mirrors `blocks/loader.ts` calling `signal.throwIfAborted()`) and
// otherwise returns its resolved props unchanged.
const flakyLoader = (props: unknown): unknown => {
RequestContext?.signal?.throwIfAborted();
return props;
};

const makeContext = (): BaseContext => ({
revision: "",
resolveChain: [],
resolveId: "1",
resolverId: "unknown",
// A shared *named* block (top-level decofile key): resolution is memoized
// by key in `context.memo`, so every reference shares one promise.
resolvables: {
SharedBlock: { __resolveType: "flakyLoader", title: "hello" },
},
resolvers: { ...defaults, flakyLoader } as unknown as ResolverMap,
resolveHints: {},
memo: {},
runOnce: (_key, f) => f(),
resolve: <T>(data: unknown) => data as T,
});

const tryResolve = async (ctx: BaseContext) => {
try {
return await resolve<{ title: string }>("SharedBlock", ctx);
} catch (e) {
return `threw:${(e as Error).name}`;
}
};

await t.step(
"sequential: a live consumer re-resolves after an aborted one",
async () => {
const ctx = makeContext();
const aborted = new AbortController();
aborted.abort();

// Consumer A resolves the shared block under an already-aborted signal
// (as `website/sections/Rendering/Lazy.tsx` does to render a fallback).
const a = await RequestContext.bind(
{ signal: aborted.signal },
() => tryResolve(ctx),
)();
// Consumer B (the real render) has a live signal and must get the value.
const b = await tryResolve(ctx);

assertEquals(a, "threw:AbortError");
assertEquals(b, { title: "hello" });
},
);

await t.step(
"concurrent: a live consumer does not inherit a co-scheduled abort",
async () => {
const ctx = makeContext();
const aborted = new AbortController();
aborted.abort();

// A (aborted) and B (live) await the same in-flight memo promise.
const [a, b] = await Promise.all([
RequestContext.bind(
{ signal: aborted.signal },
() => tryResolve(ctx),
)(),
tryResolve(ctx),
]);

assertEquals(a, "threw:AbortError");
assertEquals(b, { title: "hello" });
},
);

await t.step(
"an aborted caller never writes the shared memo",
async () => {
const ctx = makeContext();
const aborted = new AbortController();
aborted.abort();

const a = await RequestContext.bind(
{ signal: aborted.signal },
() => tryResolve(ctx),
)();

assertEquals(a, "threw:AbortError");
// The doomed aborted resolution must not have populated the cache.
assertEquals(ctx.memo["SharedBlock"], undefined);
// ...so the real render still resolves it for real.
assertEquals(await tryResolve(ctx), { title: "hello" });
},
);
});
53 changes: 49 additions & 4 deletions engine/core/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ export class ReleaseResolver<TContext extends BaseContext = BaseContext> {
private resolveHints: ResolveHints;
private _cachedResolvers: ResolverMap<BaseContext> | null = null;
private _resolveIdCounter = 0;
/**
* Subscription to the release's `onChange`. Kept so it can be disposed when
* this resolver is replaced — otherwise every superseded resolver stays
* reachable from the provider's listener list and leaks its whole
* `resolvables`/`resolvers`/`resolveHints` graph.
*/
private releaseSubscription: Disposable | undefined;
constructor(
config: ResolverOptions<TContext>,
hints?: ResolveHints,
Expand All @@ -101,31 +108,69 @@ export class ReleaseResolver<TContext extends BaseContext = BaseContext> {
this.danglingRecover = config.danglingRecover;
this.resolveHints = hints ?? {};
this.runOncePerRelease = oncePerRelease ?? {};
this.release.onChange(() => {
this.releaseSubscription = this.release.onChange(() => {
dispatchEvent(new Event("deco:hmr"));
this.runOncePerRelease = {};
this.resolveHints = {};
this._cachedResolvers = null;
});
}

/**
* Unsubscribes this resolver from its release.
*
* `installApps` rebuilds the resolver on every decofile change
* (`currentResolver = currentResolver.with({ resolvers, resolvables })`), and
* each `new ReleaseResolver` subscribes to the same provider. Without
* dropping the previous subscription the superseded resolver is still
* referenced by the provider's listener list and can never be collected, so
* every publish permanently retains one full resolver graph.
*
* Safe to call on a superseded resolver: in-flight requests holding a
* reference keep working, they simply stop being notified of release changes
* they no longer serve. Idempotent.
*/
public dispose = (): void => {
this.releaseSubscription?.[Symbol.dispose]();
this.releaseSubscription = undefined;
};

/**
* Alias, not a forwarding method: a prototype method that calls
* `this.dispose()` throws once it is detached from the instance
* (`const d = resolver[Symbol.dispose]; d()`), while the bound field above
* survives it.
*/
readonly [Symbol.dispose] = this.dispose;

public with = (
{ resolvers, resolvables, release, danglingRecover }: ExtensionOptions<
TContext
>,
): ReleaseResolver<TContext> =>
new ReleaseResolver<TContext>(
): ReleaseResolver<TContext> => {
// Hints are derived from the release's resolvables and cached by resolveType
// (block id). When the release is swapped — as Fast Preview does to bind a
// request-scoped draft (`resolver.with({ release: draftProvider })`) — the
// base hints are stale: the SAME block id can now resolve to different
// content (e.g. a page whose `sections` was a plain array in the published
// release but a `multivariate` flag in the draft). Inheriting them makes the
// draft resolve against the published shape and silently drop everything the
// old hints don't cover. This mirrors the `release.onChange` invariant in the
// constructor, which already clears hints whenever the release changes.
const releaseChanged = release !== undefined && release !== this.release;
return new ReleaseResolver<TContext>(
{
release: release ?? this.release,
danglingRecover: danglingRecover ?? this.danglingRecover,
resolvables: { ...this.resolvables, ...resolvables },
resolvers: { ...this.resolvers, ...resolvers },
},
{ ...this.resolveHints },
releaseChanged ? {} : { ...this.resolveHints },
{
...this.runOncePerRelease,
},
);
};

public getResolvers(): ResolverMap<BaseContext> {
return this._cachedResolvers ??= {
Expand Down
Loading