mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
vinext@0.1.0
181 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cda19e1c58 |
fix(app-router): preserve recent segment state with Activity BFCache (#1739)
* fix(app-router): preserve recent segment state with Activity BFCache Fresh App Router navigations were reusing the active React tree when moving between dynamic segment values. That leaked client state into a newly entered segment and made the upstream back-forward-cache suite fail before it even reached browser traversal assertions. The root cause was that vinext had history-entry bfcacheId plumbing but no separate segment state-key cache. Next.js treats those identities separately: render state is keyed by segment identity/pathname, while useRouter().bfcacheId identifies a concrete history entry. Add state-key maps for app router slots, render retained entries through React.Activity when cacheComponents is enabled, keep the active one-entry keyed reset path otherwise, and port focused Next.js regression coverage for the three-entry BFCache behavior. * fix(bfcache): store stateKeyMap in retained slot entries and re-provide on render Review feedback requested two changes: 1. Add stateKeyMap to BfcacheSlotEntry so each retained BFCache entry carries the state-key map that belonged to it when active, rather than borrowing the current navigation's map. This prevents nested hidden entries from being rendered with old elements but new pathname-derived state keys — an identity corruption bug that upstream Next.js avoids by rendering each retained entry from its own state. 2. Add a nested dynamic regression test: /nested/[section]/item/[id] with client counters in both the [section] layout and [id] page. The existing flat /page/[n] tests do not exercise nested retained-entry identity, which is exactly where the missing state-key-map bug lives. Changes: - BfcacheSlotEntry gains stateKeyMap?: Readonly<Record<string, string>> - updateBfcacheSlotEntries accepts and stores an optional stateKeyMap argument - BfcacheSlotBoundary stores the current stateKeyMap alongside elements when caching the active entry - Both the Activity visible/hidden path and the non-cacheComponents single-entry path now wrap rendered entries in BfcacheStateKeyMapContext.Provider using the entry's own stateKeyMap (falling back to current map for forward compatibility) - New e2e test verifies nested layout + page counter preservation across segment navigations * refactor(bfcache): extract shared provider stack into BfcacheEntryProviders BfcacheSlotBoundary rendered the BfcacheStateKeyMapContext / ElementsContext / SegmentContext provider stack in two places: the one-entry keyed reset path and the per-entry Activity retention list. The two stacks were identical except for where the React key sat, so the retained-entry bookkeeping and its render shape were tangled together and harder to audit than needed. Extract BfcacheEntryProviders as a pure presentational helper and render it from both paths. The keyed-reset path keeps its remount semantics: the key now sits on the BfcacheEntryProviders element, so a fresh active stateKey still remounts the whole provider subtree. Hook ordering is unchanged. All hooks stay above the early returns in BfcacheSlotBoundary, so the boundary still satisfies the Rules of Hooks across the activeStateKey undefined-to-defined transition. Covered by the slot BFCache entry-state suite in tests/shims.test.ts. * test(bfcache): isolate cacheComponents BFCache coverage in a dedicated fixture The back/forward-cache e2e suite ran against the shared app-basic fixture with cacheComponents: true. app-basic backs the single app-router Playwright server that every app-router spec uses, so enabling cacheComponents globally changed a core DOM invariant for unrelated tests: inactive route trees stay mounted as hidden Activity DOM. The contamination already leaked a :visible workaround into the interception-modal bfcacheId spec, which had nothing to do with Activity. cacheComponents is an opt-in semantic mode, not default app-router behaviour, so it does not belong on the shared baseline fixture. Move the BFCache routes into a dedicated tests/fixtures/app-bfcache fixture with cacheComponents: true, served by a new app-router-bfcache Playwright project on port 4183, and run the back-forward-cache spec there against the project baseURL. The :visible locators stay in that spec, where duplicate hidden DOM is the expected contract. Remove cacheComponents and the BFCache routes from app-basic so the shared fixture models default behaviour again, and revert the :visible workaround in use-router-bfcache-id.spec.ts now that the modal route no longer retains hidden DOM. Fix the nested item page import while moving it: nested/[section]/item/[id]/ page.tsx imported ../counter, but the counter lives two levels up at nested/[section]/counter.tsx, so it must be ../../counter. Validation: PLAYWRIGHT_PROJECT=app-router-bfcache (5 passed) and the full use-router-bfcache-id spec under app-router (11 passed), plus the slot BFCache unit suite (1172 passed) and vp check (formatting + lint + types clean). * ci: run the app-router-bfcache e2e project in the matrix The dedicated app-router-bfcache Playwright project was added to playwright.config.ts but not to the CI E2E matrix, so the back/forward-cache specs never ran in CI. Add it to the matrix project list so the BFCache suite runs on every push. It uses the default chromium browser, so the existing setup and browser-cache steps apply unchanged. * fix(bfcache): keep persisted slots mounted when cacheComponents is off Without cacheComponents there is no Activity retention, yet BfcacheSlotBoundary still keyed the active entry by its segment stateKey. That stateKey tracks the pathname (createBfcacheSegmentIdentity), so any slot whose identity moves with the URL (shared layouts, interception source slots) was remounted on navigation, discarding client state that survives a normal route change. The baseline router reconciles these slots in place. This regressed seven existing app-router e2e tests once cacheComponents was no longer masking it on the shared fixture: interception source-context and modal preservation in advanced.spec.ts, and layout-persistence.spec.ts dynamic-segment layout counter survival. Fresh-entry reset is driven by userland bfcacheId keying in the fixtures, not by remounting the slot subtree, so the keying was pure overreach in the non-cacheComponents path. Render the single active entry unkeyed when cacheComponents is off so the boundary reconciles in place like the baseline. The keyed Activity retention path is unchanged for cacheComponents. Validation: full app-router project 436 passed / 0 failed (the seven regressions now pass); use-router-bfcache-id 11 passed (fresh-push reset intact); app-bfcache 5 passed (Activity retention intact); shims unit suite 1024 passed. * docs(test): point bfcacheId spec comment at the app-router-bfcache project The header noted that Activity-backed preservation is covered in back-forward-cache.spec.ts, but that spec moved to the dedicated app-bfcache fixture under the app-router-bfcache project. Name the project and path so the cross-reference stays accurate. * docs: align cacheComponents comment with unkeyed active-segment behavior * test(bfcache): assert fresh bfcacheId after Activity-restored return * style: collapse locator chains per oxfmt * refactor(bfcache): split slot entry order from snapshots * fix(bfcache): keep non-cacheComponents slot path baseline * fix(bfcache): use string cacheComponents define * test: remove external fetch from revalidate tag fixture * fix(bfcache): harden Activity state key retention * docs(bfcache): document cacheComponents Activity caveats * fix(bfcache): preserve navigation context for fallback SSR Fallback boundary rendering can outlive the original request-scoped navigation context. That made app SSR fail fast with a missing navigation context when loading-boundary digest routes rendered their HTTP fallback shell. The SSR boundary contract still requires a navigation context for real request renders, but boundary renderers now synthesize the same typed shape from the request URL and matched params when request scope has already been cleared. * refactor(bfcache): normalize pathname in state-key map, document invariant callers Moves hydration-pathname normalization into createBfcacheSegmentStateKeyMap so SSR and client always produce byte-identical Activity keys. Documents the requireNavigationContext guaranteeing callers per review feedback. * fix(bfcache): tolerate malformed state-key pathnames * docs(bfcache): document known Next.js divergence and dead-branch contract * chore(deps): update lockfile for vite 0.1.24 * fix(app-router): preserve encoded delimiters in bfcache keys * docs(bfcache): track router-owned retention follow-up * chore: retrigger ci * fix(app-router): preserve redirect bridge fallback |
||
|
|
aab9240e10 |
fix(app-router): hard navigate streamed redirects (#1742)
* fix(app-router): hard navigate streamed redirects * fix(navigation): guard page redirect marker checks * test(app-router): count redirect document loads |
||
|
|
1a480016f6 |
fix(app-router): track searchParams access for static bailout (#1788)
* fix(app-router): track searchParams access for static bailout App Router pages that awaited or used the searchParams prop during prerender could still be classified as static when the prerender request had an empty query string. That seeded no-query output into the deploy cache and served it for requests like ?search=hello. The dynamic decision was based on query-string content instead of page prop access. Observe the searchParams thenable itself, including React use() status reads, and mark searchParams dynamic only when the prop is consumed. * chore: rerun ci * fix(app-router): avoid static cache for searchParams pages * fix(app-router): tolerate synthetic page paths in manifest codegen * fix(app-router): observe searchParams access for static bailout * fix(app-router): preserve app page cache hits * fix(app-router): observe loading search params during render * refactor(app-router): extract searchParams probe fan-out into helper Move the per-request page probe fan-out (matched page + parallel slot pages + interception page) out of the generated RSC entry and into a unit-testable buildAppPageProbes() helper in server/app-page-probe.ts, keeping entries/app-rsc-entry.ts thin (AGENTS.md guidance). Addresses bonk review concern #3 on #1788. Document the known slot over-bail (probing inactive parallel-route slot pages can mark an otherwise-static page dynamic; fails safe toward dynamic) and track it as a caching-efficiency follow-up in #1798 (bonk review concern #1). * fix(app-router): skip probing interception-overridden slot pages Narrow the searchParams probe fan-out so it only probes page components that actually render for the request. When an interception matches, it replaces the page of the slot named by intercept.slotKey (the element builder sets overrides[slotKey].pageModule to the interception page, which wins over slot.page in app-page-route-wiring.tsx). We now probe the interception page in place of that slot's own page instead of probing both — probing the overridden slot page marked an otherwise static request dynamic for a component that never renders. Fixes the over-bail flagged in review of #1788. * docs(app-router): clarify buildAppPageProbes slot precision Address review feedback: the doc comment overstated precision without explaining why non-overridden slots are exact. Spell out that a slot with a page.tsx always renders that page (so probing it is correct), default-only slots have no slot.page.default so probing is a no-op (not an over-bail), and a default.tsx awaiting searchParams is backstopped by the real render's observation (so no under-bail). * fix(app-router): gate searchParams interception probe on isRscRequest Interception only fires for RSC navigations (resolveAppPageInterceptState returns kind:none when !isRscRequest, app-page-request.ts:324). The probe fan-out called findIntercept unconditionally, so on HTML requests it would skip the matched route's slot page (which DOES render normally) and probe the interception page (which never renders). buildAppPageProbes now ignores the interception match when !isRscRequest, so HTML requests probe every slot's own page and skip the interception probe. The source-route interception case (a different route renders) never reaches this probe: dispatchAppPage returns the intercepted response before calling probePage, so any interception seen here is the current-route override case. Documented both in the buildAppPageProbes doc comment. Addresses review feedback on #1788. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
cf04ca63e3 |
fix(form): DISALLOWED_FORM_PROPS stripping, file input warning, viewport prefetch, pages-router E2E (#1752)
* fix(form): implement DISALLOWED_FORM_PROPS, file input warning, viewport prefetch, and pages-router E2E - Strip method/encType/target props with dev console.error (Gap 1) - Warn in dev when FormData contains a File value (Gap 2) - Add viewport IntersectionObserver prefetch for App Router (Gap 3) - Add prefetch prop (false | null) to FormProps - Add pages-dir fixture and E2E spec for Pages Router form tests (Gap 4) - Fix Rules of Hooks: move useRef/useCallback/useEffect before conditionals - Update unit tests: stub React 19 dispatcher for hook-safe direct rendering Closes #1355 * test(form): fix 'renders a form with method prop' to assert stripping behavior * fix(form): address code-review findings — JSDoc, parity warnings, prefetch dedup - Fix misleading JSDoc: pages-dir prefetch prop "has no effect" (not "emits a warning") - Add dev warning for invalid prefetch value (not null/false) - Add dev warning for replace/scroll/prefetch passed with a function action - Gate viewport-prefetch IntersectionObserver with getPrefetchedUrls() / hasPrefetchCacheEntryForNavigation() dedup check to match link.tsx - Add 5 new test cases covering the three new warning paths * style(form): fix formatting in form.test.ts * fix(form): match Next.js warning text verbatim, reuse useMergedRef, skip prefetch for unsafe actions Addresses bonk review feedback: - Invalid prefetch warning now matches Next.js verbatim: "The `prefetch` prop of <Form> must be `false` or `null`" - Function-action warnings split into the two distinct Next.js messages (one for `prefetch`, one for `replace`/`scroll` with doc links), gated separately to match app-dir/form.tsx - Reuse the shared useMergedRef helper instead of hand-rolling ref merge - Viewport prefetch now early-returns on unsafe actions, matching the submit-path isSafeAction gate - Update form.test.ts assertions to the new verbatim text * fix(form): run dev-mode prop validations before DISALLOWED_FORM_PROPS strip to match Next.js order * fix(form): match Next.js validation ordering and bail for server/client action submitters Addresses bonk review feedback: - Run prefetch/replace/scroll dev validations before the DISALLOWED_FORM_PROPS strip loop, matching upstream app-dir/form.tsx ordering - Bail out of GET interception for React server-action submitters (`name` starting with $ACTION_ID_/$ACTION_REF_, dev-only silent bail) and client-action submitters (`formAction="javascript:..."`), ported from Next.js form.tsx / form-shared.tsx - Add unit tests covering both submitter bail paths * fix(form): match Next.js dev-validation order and gate submitter warnings Addresses bonk re-review parity findings: - Gate the three console.error calls in hasUnsupportedSubmitterAttributes behind the dev check, matching Next.js form-shared.tsx (the bail itself still runs in all environments). Previously they fired in production. - Hoist checkFormActionUrl(action, "action") into the top dev-validation block so it runs first, matching upstream app-dir/form.tsx ordering. * refactor(form): drop dead-code GET method check for upstream parity Addresses bonk re-review finding: the form-level `method` prop is stripped via DISALLOWED_FORM_PROPS and any non-GET submitter `formmethod` override is already caught by hasUnsupportedSubmitterAttributes, so the explicit getEffectiveMethod GET check was dead code. Upstream app-dir/form.tsx has no equivalent check. Remove it (and the now-unused getEffectiveMethod helper) and document why the method is always GET at that point. * fix(form): align viewport prefetch fetch with link.tsx (priority + purpose) Addresses bonk re-review finding: the Form viewport prefetch fetch() omitted the `priority: "low"` and `purpose: "prefetch"` options that link.tsx passes (link.tsx:445-451). Without them, RSC prefetches competed with higher-priority requests and weren't tagged as prefetches in devtools / Sec-Purpose. Align for consistency with the Link prefetch path. * fix(form): thread interception context + mounted slots into viewport prefetch Addresses bonk re-review substantive finding: the viewport prefetch built headers with createRscRequestHeaders() (no interceptionContext, no mounted-slots header) and keyed the prefetched set on the bare rscUrl. The actual navigation (and link.tsx / the router) compute getPrefetchInterceptionContext(href) + getMountedSlotsHeader() and key on encodeCacheKey(rscUrl, interceptionContext). For intercepted target routes or pages with mounted parallel slots, the Form's prefetched cache key wouldn't match the navigation lookup, so the prefetch was a silent cache miss (wasted work, no dedup against a nearby <Link>). Mirror the Link/router computation: build context-aware headers, key the prefetched set on encodeCacheKey, and thread interceptionContext/mountedSlotsHeader through hasPrefetchCacheEntryForNavigation and prefetchRscResponse. * docs(form): note prefetch effect dep-array redundancy is lint-mandated Bonk flagged actionHref as redundant alongside action in the prefetch effect's dep array. It is derived purely from action + the module constant, but the exhaustive-deps lint requires it to be listed since the effect references it. Document this so it isn't mistaken for an oversight. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
8d00797ada |
feat(cache): extract Cloudflare cache adapters into @vinext/cloudflare (#1748)
* refactor(cache): extract Cloudflare cache adapters into @vinext/cloudflare Move the Cloudflare KV data cache and edge CDN cache adapters out of vinext into a new publishable @vinext/cloudflare package: - cache/kv-data-adapter(.runtime).ts (KVCacheHandler, kvDataAdapter) - cache/cdn-adapter(.runtime).ts (CloudflareCdnCacheAdapter, cdnAdapter) tpr.ts stays in vinext. vinext now depends on @vinext/cloudflare (workspace:*) and the package declares vinext as a peer dep; both build from source via tsconfig paths so there is no build-order cycle. The vinext/cloudflare barrel still re-exports KVCacheHandler for back-compat. Wires up tsconfig paths, a vitest source alias, root build/postinstall, and the preview/publish workflows for the new package. Updates internal consumers (apps/web, examples/workers-cache), docs, and tests. * ci(create-next-app): install @vinext/cloudflare from local tarball vinext now depends on @vinext/cloudflare, which isn't published to npm yet. The create-next-app smoke test packs vinext locally and resolves its deps from the registry, so the install (and dev server) failed with ERR_PNPM_FETCH_404 for @vinext/cloudflare. Pack @vinext/cloudflare alongside vinext and add a pnpm override in the scaffolded project pointing at the local tarball so the dependency resolves offline. * refactor(cloudflare): address review feedback - Remove the root barrel export from @vinext/cloudflare; expose only the ./cache/* subpaths via a wildcard export (no root main/types). - vinext/cloudflare re-exports KVCacheHandler from the full subpath. - Drop the redundant .npmignore (the package.json "files" allowlist already restricts the publish to dist). - Remove the unsupported imperative setCacheHandler/KVCacheHandler usage from both READMEs; the cache plugin config is the supported approach. - Simplify test wiring: drop the now-unused @vinext/cloudflare tsconfig path and dedupe the vitest source alias into a shared constant. * chore(cloudflare): drop unused vite devDependency The @vinext/cloudflare config uses vite-plus and nothing imports vite, so the vite devDependency was unused. build/check/knip stay green without it. * Apply suggestion from @james-elicx |
||
|
|
f0f6aa72e0 |
feat(cache): configure cache adapters from vite plugin config (#1733)
* feat(cache): configure cache adapters from vite plugin config
Add a `cache` option to the vinext() plugin so CDN and data cache
adapters can be declared in vite.config instead of calling
setDataCacheHandler() / setCdnCacheAdapter() from a worker entry:
vinext({
cache: {
cdn: { adapter: require.resolve('vinext/cloudflare/cache/cdn-adapter') },
data: { adapter: require.resolve('vinext/cloudflare/cache/kv-data-adapter') },
},
})
Each slot points at an adapter module whose default export is a factory
(DataCacheAdapterFactory / CdnCacheAdapterFactory). The plugin generates
a virtual:vinext-cache-adapters module that the App Router worker entry
calls per request (self-guarded, once per isolate), passing the host env
so binding-backed adapters (e.g. KV) can read their namespace.
Ships ready-made Cloudflare adapter entry points:
- vinext/cloudflare/cache/kv-data-adapter (KVCacheHandler)
- vinext/cloudflare/cache/cdn-adapter (CloudflareCdnCacheAdapter)
* feat(cache): add typed adapter builders (kvDataAdapter/cdnAdapter)
Instead of `{ adapter: require.resolve(...) }`, each adapter module now
also exports a config-time builder from the same path:
import { cdnAdapter } from 'vinext/cloudflare/cache/cdn-adapter';
import { kvDataAdapter } from 'vinext/cloudflare/cache/kv-data-adapter';
vinext({ cache: { cdn: cdnAdapter(), data: kvDataAdapter({ binding: 'MY_KV' }) } })
A builder returns a plain, serializable { adapter, options } descriptor —
it never touches the Workers runtime, so nothing throws at config / build
/ dev time when bindings aren't available. Descriptor `options` (e.g. the
KV binding name) are inlined into the generated registration module and
forwarded to the factory's { env, options } context, where the binding is
resolved lazily on the first request.
- shims/cache-adapter: descriptors + options-aware factory/context types
- kv-data-adapter: kvDataAdapter() builder + configurable binding/appPrefix/ttl
- cdn-adapter: cdnAdapter() builder
- raw { adapter, options } path form still supported
* test(cache): verify absolute (require.resolve) local adapter path bundles
Real Cloudflare build pointing cache.data at a local adapter file by
absolute path (what require.resolve('./adapter') yields). Proves the
generated registration module resolves the absolute import, bundles the
local adapter into the worker, and does not need any Workers context at
build time.
* refactor(cache): builder require.resolve + register across all routers/runtimes
Addresses review feedback:
* Move adapters into their own runtime modules instead of re-exporting.
Each adapter is now a builder module (kv-data-adapter.ts / cdn-adapter.ts)
plus a sibling *.runtime.ts holding the default-export factory. Type
definitions have a single home in shims/cache-adapter.ts (dropped the
re-export shim; index.ts imports the config type from there).
* The exposed builder utility resolves the relative runtime path internally
via import.meta.resolve (the ESM require.resolve), so the descriptor carries
an absolute path to the runtime factory rather than a bare specifier — the
example is just kvDataAdapter({ binding }), no require.resolve at the call site.
* Register configured cache handlers EVERYWHERE, not just the App Router worker:
- App Router: the generated RSC entry passes registerConfiguredCacheAdapters
into createAppRscHandler, which calls it per request — covering Workers,
the Node server, and dev through the one shared handler.
- Pages Router: the generated server entry registers in renderPage and
handleApiRoute (Node/dev), and the generated worker registers with env
(Workers, for KV bindings).
Registration self-guards (first call with real env wins) and is now resilient:
a factory that throws on an incompatible runtime is logged and skipped, so the
default handler stays in place instead of failing every request.
Tests: generator-level assertions that every router/runtime entry wires
registration, plus the existing builder/codegen/factory and full-build coverage.
vp check clean; app-router (339) and pages-router (272) suites pass.
* refactor(cache): keep all Cloudflare adapter code under cloudflare/
The adapter factory contract lived in shims/cache-adapter.ts (outside
cloudflare/), and the Cloudflare adapters reached out to it. Move the
contract into cloudflare/cache/adapter.ts so every Cloudflare-specific
cache adapter file is self-contained under cloudflare/ — importing only
cloudflare-local modules and the core CacheHandler/CdnCacheAdapter
interfaces it implements.
The plugin's config schema (CacheAdapterDescriptor / VinextCacheConfig)
is genuinely framework-level (it's the vinext() `cache` option), so it
moves into the codegen module the plugin already owns; index.ts imports
it from there. Builders return a structural { adapter, options } so they
don't import the descriptor type either. Deletes shims/cache-adapter.ts.
* refactor(cache): merge KV/CDN classes into the runtime adapter files
All Cloudflare cache code now lives in one directory, cloudflare/cache/,
and each runtime file holds both the implementation class and its
config-driven factory (no separate class module to reach for):
- kv-cache-handler.ts -> cache/kv-data-adapter.runtime.ts
(KVCacheHandler + ENTRY_PREFIX + createKvDataCacheAdapter default export)
- cloudflare-cdn-cache.ts -> cache/cdn-adapter.runtime.ts
(CloudflareCdnCacheAdapter + createCloudflareCdnCacheAdapter default export)
Updated importers: cloudflare/index.ts re-exports the classes from the
runtime files, tpr.ts pulls ENTRY_PREFIX from there, shims/cdn-cache.ts
imports the edge adapter from there, and the tests follow the moved paths.
git mv preserves history.
vp check clean; cache/kv/cdn/app-route/tpr/shims suites pass (1300+ tests).
* chore(cache): trim low-value comments added in this branch
Remove narrating/redundant comments that just restated the code; keep
the non-obvious why (registration ordering/resilience, import.meta.resolve
rationale, edge cache-control semantics). No code changes.
* review: address PR #1733 feedback
- Make registerCacheAdapters a required field on the RSC handler options
(the generated entry already passes it; test factory updated).
- Remove the separate cloudflare/cache/adapter.ts contract file; inline the
factory param types directly into the two runtime adapters.
- Drop the CloudflareCdnCacheAdapter re-export from cloudflare/index.ts.
- Fold the virtual:vinext-cache-adapters declaration into global.d.ts and
delete the standalone .d.ts.
- Remove the ./cloudflare/cache/* package.json export for now; README uses a
local-adapter require.resolve example with a note that the built-in adapter
export paths are pending.
- Rename the config-driven KV default binding to VINEXT_KV_CACHE (imperative
deploy/tpr path keeps VINEXT_CACHE — flagged on the thread).
* refactor(cache): align KV binding name to VINEXT_KV_CACHE everywhere
Rename the KV cache binding from VINEXT_CACHE to VINEXT_KV_CACHE across the
whole codebase so the config-driven adapter, the imperative deploy-generated
worker, TPR's wrangler detection, and the apps/web example all agree. The
unrelated X-Vinext-Cache response-header constant (VINEXT_CACHE_HEADER) is
untouched.
* tidy
* .
* .
* .
* .
* .
* Move apps/web cache to plugin config
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
---------
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
|
||
|
|
d8ca7e8177 |
test(i18n): align shallow redirect-skip assertion with Next.js history state (#1729)
The i18n sticky-locale change (
|
||
|
|
f4d13e25b9 |
fix(i18n): make locale sticky across client navigations (#1407)
* fix(i18n): make Pages Router locale sticky across client navigations
Refs #1336 (item 2).
Writes a Next.js-compatible history state shape on every router
push/replace, captures the active locale in `state.options.locale`, and
teaches the popstate handler to:
1. Ignore history entries written by third-party code (no `__N: true`).
2. Drop the first popstate when it carries the same locale + `as` as the
current page (Safari tab-restore / BFCache replay parity).
3. Honour the locale recorded in history state when re-fetching the page
on back/forward, so default-locale roots still go through their
locale-qualified HTML endpoint.
This matches the Next.js behaviour exercised by
`test/e2e/ignore-invalid-popstateevent` and the client half of
`test/e2e/i18n-preferred-locale-detection`.
Scope boundary: the server-side `Set-Cookie: NEXT_LOCALE=...` on initial
Accept-Language detection is intentionally NOT modified here. The
existing `parseCookieLocaleFromHeader` already gives the cookie priority
over Accept-Language, and changing where the cookie is written would
overlap with #1336 item 4 (default-locale prefix normalisation).
* fix(i18n): preserve back/forward navigation under new history-state shape
The previous commit attached `__N: true` to router-owned history entries,
but two regressions slipped in:
1. `saveScrollPosition` merges scroll fields onto the *current* entry
before a push. When the current entry was the initial document load
(`state === null`), the merge produced a state object without `__N`,
which the popstate foreign-state filter then dropped — breaking
browser back/forward.
Fix: when the existing state is null, mint a router-shaped state
(`{ __N: true, url, as, options, key }`) before merging the scroll
fields, so every entry the router touches carries `__N`.
2. The Safari-replay filter compared `state.as` against `window.location`,
but `window.location` has already changed by the time popstate fires
on a real back/forward. That made every genuine back navigation look
like a replay and get ignored.
Fix: compare against `_lastPathnameAndSearch` (the URL the router
last actively navigated to). After a real back, that tracker still
points at the entry we were on, so a genuine navigation differs and
passes the filter — matching Next.js's `state.as === this.asPath`
semantics.
Verified locally:
- tests/pages-router-i18n-sticky-locale.test.ts: 7/7 pass
- tests/shims.test.ts: 944/944 pass
- tests/e2e/pages-router/{navigation,before-pop-state,hydration,router-events}.spec.ts:
28/28 pass (these were the CI failures from the previous commit).
* fix(i18n): stamp initial history entry with active locale on install
Closes the remaining gap in the popstate locale-recovery path: the
initial document entry has `state: null` until the first push, so a
back-navigation to it would fall back to the live `window.__VINEXT_LOCALE__`
— which an intervening locale-changing push may have flipped. Stamping
router-shaped state at install captures the SSR-resolved locale into
`options.locale` and lets the popstate handler restore the correct locale.
Also:
- Strip the hash from `state.as` so the Safari-replay filter's
`withBasePath(state.as) === _lastPathnameAndSearch` comparison works for
entries created from a hash-bearing URL (the tracker never carries a hash).
- Drop the unnecessary `withBasePath as withBasePathPrefix` import alias.
Adds 3 tests: install-time stamping, no-clobber of pre-existing state, and
the back-to-initial-entry locale-recovery regression.
* fix(i18n): prefer active locale over default + scope Safari-replay filter
Three small follow-ups discovered by CI on top of the i18n-sticky-locale
work:
1. `getCurrentBrowserLocale` was falling back to `__VINEXT_DEFAULT_LOCALE__`
before `__VINEXT_LOCALE__` when no path prefix was present, which threw
away the active locale on default-locale paths served under a
non-default locale. Flip the fallback order so the active locale wins —
exactly what the new `pages-router-i18n-sticky-locale.test.ts` 'Router
.push without an explicit locale' case asserts.
2. The Safari-replay filter on the first popstate event was running even
after the user had already navigated. After a hash-only `Router.push`,
the entry's `state.as` is recorded without a hash, which happens to
match `_lastPathnameAndSearch` (also without a hash); the filter then
incorrectly dropped a genuine `goBack()` popstate, freezing the E2E
router-events suite. Gate the filter behind a new `_routerDidNavigate`
flag so it only fires during the synthetic initial-load replay window.
3. Update the 6 `expect(pushState).toHaveBeenCalledWith({}, ...)`
assertions in `tests/shims.test.ts` to use
`expect.objectContaining({ __N: true })`, since pushState now carries a
Next.js-compatible state object instead of `{}`.
* test(i18n): align unprefixed-path locale tests with sticky-locale behavior
The previous "treat unprefixed path as default locale" assertions in
tests/link.test.ts and tests/shims.test.ts contradicted the intentional
sticky-locale flip in
|
||
|
|
82b2720701 |
feat(app-router): support useRouter bfcacheId semantics (#1588)
* feat(app-router): implement useRouter bfcacheId semantics * fix(app-router): refine bfcacheId navigation behavior * test(app-router): align bfcache history state expectations * fix(app-router): handle catch-all bfcache identities * fix(app-router): align bfcache context semantics * fix(app-router): avoid hidden bfcache DOM retention * fix(app-router): hydrate bfcache ids from history state * fix(app-router): handle bfcacheId redirects and slots * test(app-router): expect active slot route ids * Fix bfcache id history restoration * Clarify bfcache id sentinel formatting * Address bfcacheId review feedback * Tie bfcacheId sentinels to shared constants * Address bfcacheId review clarity * Tighten bfcache slot identity check * Deduplicate bfcache history sync check * Handle empty bfcache segment ids * Fix intercepted active slot route ids * Clarify bfcacheId form state coverage * Address bfcacheId review clarity * fix(app-router): skip invisible tree-path segments in bfcache identity getVisibleTreePathSegments only stripped route groups, not parallel @slots or "." default segments. Tree paths contain raw filesystem segments, so a slot preceding visible URL segments over-counted the consumed pathname segments and produced a wrong identity prefix. That re-minted the bfcacheId for layouts/slots that actually persisted across parallel-route navigations (e.g. @modal), diverging from Next.js bfcacheId semantics. Delegate to the canonical isInvisibleSegment helper so the filter stays in sync with the route graph, and add a parallel-slot layout regression test (fails without the fix). Also tighten the hydration-map unit test to actually exercise the "no seeding from minted ids" property instead of asserting a local literal, and correct the SSR bfcache context comment. * fix(app-router): move isInvisibleSegment to browser-safe utils The previous commit imported isInvisibleSegment from app-route-graph.ts into app-browser-state.ts, but app-route-graph.ts imports node:path, node:fs and node:crypto. Pulling it into the browser bundle broke client hydration (app-with-src E2E: instrumentation-client and dev-overlay-recovery timed out waiting for hydration / the navigation runtime). Move the pure isInvisibleSegment helper into the browser-safe routing/utils.ts module (already in the client bundle), re-export it from app-route-graph.ts so Node-side import sites keep working, and have app-browser-state.ts import it from utils.js. The app-route-graph type import is now type-only. * refactor(app-router): address bfcacheId review feedback - Reject history entries with a missing bfcache version in the document-scoped version gate (stop coercing null -> 0) so stale ids from older builds / external pushState can't be restored into a fresh document on the version-0 path. - Parse AppElements metadata once per element map per commit and resolve slot bindings via a precomputed Map (was repeated readMetadata parses plus an O(slots^2) find on every navigation commit). - Read single history-state keys without cloning the whole state object; the write path still clones before mutating. - Dedupe the two identical non-negative-safe-integer validators and share path-segment / catch-all helpers via browser-safe routing/utils. - Move tree-path consumed-segment counting into routing/utils and fix optional-catch-all over-counting. - Drop the unused bfcacheIds param from createHistoryStateWithPreviousNextUrl. * refactor(routing): make catch-all segment predicates module-private isCatchAllSegment/isOptionalCatchAllSegment are only used by countConsumedPathnameSegments in the same module; drop the exports to clear knip's unused-export warning. * test(app-router): lock in bfcache version-gate rejection of missing versions Extract the version-gate decision into a pure, exported isHistoryStateBfcacheVersionCurrent(state, currentVersion) in app-history-state (re-exported via app-browser-state); the browser entry's isCurrentBfcacheVersion now delegates to it. Add a direct regression test asserting a history entry with bfcache ids but no version key is NOT treated as current even when the current version is 0 — the exact cross-document stale-id hole that the earlier `?? 0` coercion left open. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
1047cccd8a |
fix(metadata): omit unused parent arg for cached generateMetadata (#1719)
* fix(metadata): omit unused parent arg for cached generateMetadata A module-level `'use cache'` directive wraps `generateMetadata` in the cache runtime. vinext always called it as `generateMetadata(props, parent)`, where `parent` resolves to the merged ancestor metadata. When an ancestor sets `metadataBase: new URL(...)`, the cache-key encoder (`encodeReply`) awaited the parent promise and tried to serialize the `URL` instance, logging React's "Only plain objects can be passed to Server Functions from the Client. URL objects are not supported." warning. Match Next.js, which omits the `parent` argument for `generateMetadata` functions that don't use it (resolve-metadata.ts `getResult` / `useCacheFunctionInfo.usedArgs[1]`): - `resolveModuleMetadata` now only passes `parent` when `generateMetadata` declares it (`fn.length >= 2`). - `registerCachedFunction` preserves the original function's arity on the wrapper so the arity check works for cached functions. Adds regression tests for the arity gating, metadataBase preservation, and arity-preserving wrapper. * docs(metadata): note arity approximates parent usage; drop dead try/catch Address /bigbonk review feedback: - Document that fn.length approximates Next.js's usedArgs[1] analysis and diverges on default-parameter signatures. - Remove the dead try/catch around Object.defineProperty (function length is always configurable per spec). |
||
|
|
ffc8e68c4a |
fix(shims/head): match Next.js charset/viewport order and merge _document.getInitialProps head (#1677)
* fix(shims/head): match Next.js charset/viewport order and merge _document.getInitialProps head (#1569) Pages Router head emission did not match Next.js's canonical order (`<meta charset>` then `<meta viewport>` then user tags, all with `data-next-head=""`) and ignored head tags returned from a user `_document.getInitialProps()`. - `next/head` shim now seeds `defaultHead()` (charset + viewport) into the SSR collector before user tags, mirroring `defaultHead()` in `next/src/shared/lib/head.tsx`. They flow through the same dedupe pipeline so users can still override via `key="charset"` / `key="viewport"`. - Serialised attribute names follow HTML conventions (`charSet` -> `charset`, `httpEquiv` -> `http-equiv`, etc.), matching Next.js's `test/e2e/next-head/index.test.ts` exact-string assertion. - `next/document` shim no longer hardcodes charset/viewport in `<Head>` (those defaults now flow from `next/head`); fallback HTML shells in `dev-server.ts` and `pages-page-response.ts` likewise drop the duplicates. - New `setDocumentInitialHead()` accessor and ALS-backed state lets the SSR pipeline forward head tags returned by user `_document.getInitialProps()` into the same dedupe pipeline as `next/head` children. Both the dev server and the prod `renderPagesPageResponse` invoke `_document.getInitialProps()` when present and pass the result through. Adds focused regression coverage in `tests/head.test.ts` for ordering, default attribute serialisation, key-based overrides, and the `_document.getInitialProps()` merge path. Refs #1569. * fix(pages-router): address review feedback on _document head merge - Extract shared `callDocumentGetInitialProps` helper under `server/document-initial-head.ts` so both `dev-server.ts` and `pages-page-response.ts` go through the same code path. The previous duplicated try/catch blocks drifted independently and were the kind of runtime-behavior that AGENTS.md says should live in a helper. - Skip the call when `DocumentComponent.getInitialProps` is still the unmodified default inherited from vinext's `next/document` shim. Comparing against the captured base reference (`DEFAULT_GET_INITIAL_PROPS`) distinguishes a user override from the default — extending `Document` without overriding the method inherits the same static function, so invoking it on every render is wasted work. - Use `jsxAttrToHtml` on the client-side `_applyHeadPropsToElement` path so `httpEquiv` is set as `http-equiv` rather than `httpequiv` (lowercased by `setAttribute`). Matches the SSR output and prevents a hydration mismatch when SSR emits the hyphenated form. - Update stale `tests/document.test.ts` Head assertions: the shim's `<Head>` no longer hardcodes charset/viewport — those flow through `next/head`'s `defaultHead()` so the dedupe pipeline handles them. |
||
|
|
87479df147 |
fix: bound default memory cache size with LRU eviction (#1699)
MemoryCacheHandler used an unbounded Map for default ISR/fetch/cache storage. Add a Next-style memory cap with approximate byte sizing and least-recently-used eviction. Behavior: - default max size is 50 MiB, matching Next.js cacheMaxMemorySize default - cacheMaxMemorySize: 0 disables default memory storage - entries larger than the configured cap are not stored - get() refreshes LRU order - tag invalidation metadata is capped to 10k entries Wire next.config.js cacheMaxMemorySize into both App and Pages generated entries via configureMemoryCacheHandler(). Add focused unit coverage for LRU eviction, get() recency refresh, and disabled storage. |
||
|
|
a6a279509c |
fix(shims): throw Pages Router parity error from unstable_retry (#1448) (#1670)
Closes the Pages Router parity gap noted as a follow-up in #1206. When `unstable_catchError`'s boundary is rendered under the Pages Router, `unstable_retry()` now throws Next.js's verbatim error message ("`unstable_retry()` can only be used in the App Router. Use `reset()` in the Pages Router.") instead of calling App Router's `refresh()` (which is a no-op there and leaves the user stuck on the error). Detection mirrors Next.js's own implementation in `packages/next/src/client/components/catch-error.tsx`: the inner class component reads `RouterContext` via `static contextType`. The wrapper stays a pure JSX factory so existing introspection-style unit tests that invoke it as a bare function continue to work. Regression test ported from `test/e2e/app-dir/catch-error/catch-error.test.ts` — "should throw when unstable_retry is called on Pages Router". |
||
|
|
33be785d43 |
fix(app-router): allow draftMode() reads inside cache scopes (#1489) (#1669)
Calling `await draftMode()` and reading `.isEnabled` inside a `"use cache"` or `unstable_cache()` callback now works the same as Next.js: the non-mutating reads succeed and reflect the `__prerender_bypass` cookie, while only the side-effecting `enable()` / `disable()` calls throw when invoked inside a cache scope. Previously vinext threw eagerly on `draftMode()` itself, which caused fixtures such as `app-static`'s `unstable-cache/dynamic` page (which reads `(await draftMode()).isEnabled` inside the cached function) to surface as empty/undefined values in the Next.js deploy suite. Ported from `.nextjs-ref/packages/next/src/server/request/draft-mode.ts` (`getDraftModeProviderForCacheScope` + `trackDynamicDraftMode`). |
||
|
|
98c8286fdc |
test(pages-router): shallow Router.push bypasses redirect for as (#1522) (#1681)
Port the Next.js "should not apply client router filter on shallow" test
(test/e2e/app-dir/app/index.test.ts) so we lock in vinext's behaviour:
`Router.push(url, as, { shallow: true })` must update history to `as`
(/redirect-1) without fetching the page HTML, even when `as` matches a
server-side `redirects` rule from next.config.js.
vinext already produces the correct behaviour — `performNavigation`
skips `runNavigateClient` when `shallow` is true, so the server-side
redirect table never runs and `window.history.pushState` lands on
`as`. The test guards against future regressions (e.g. introducing a
Next.js-style client router bloom filter without exempting shallow
updates) and serves as the parity counterpart to Next.js's own bypass
in `_bfl` in packages/next/src/shared/lib/router/router.ts.
Refs #1522.
|
||
|
|
c8b8fc3370 |
fix(cache): updateTag throws outside Server Action (#1485) (#1641)
Match Next.js semantics: `updateTag` from `next/cache` must throw when
called outside a Server Action context (e.g. in a Route Handler or
during render), and only the in-action invariant invokes the cache
tag invalidation.
Detect the headers access phase ("action") via the existing shim and
throw the canonical error message otherwise. Existing in-action
behavior (immediate tag expiry via the active cache handler) is
preserved.
|
||
|
|
53a97e2002 |
fix(navigation): stabilize Pages Router useParams snapshot for SSR (#1639)
`useParams()` / `useSearchParams()` / `usePathname()` from `next/navigation`
work as cross-router compat shims under Pages Router, deriving their state
from `getPagesNavigationContext()` published by `next/router`. React's
`useSyncExternalStore` requires `getServerSnapshot` to return a stable
reference (Object.is) across calls in a single render — otherwise React
logs:
"The result of getServerSnapshot should be cached to avoid an infinite loop"
and may re-render until the snapshots stabilize, which is wasteful and a
hydration-mismatch hazard. The SSR branch of `getPagesNavigationContext`
was returning a fresh object each call.
Cache the shape on a `WeakMap` keyed on the ALS-isolated SSR context, so
the same `{ pathname, searchParams, params }` shape (with reference-stable
`params` / `searchParams`) is returned for every hook call inside a single
request. The WeakMap key is per-request, so concurrency safety is
preserved.
Adds a Vitest unit assertion (reference equality across repeated SSR
calls) and a Playwright regression matching the Next.js deploy-suite case
(`test/e2e/app-dir/use-params/use-params.test.ts` — 'should work on pages
router'): a Pages Router dynamic page using `useParams()` inside a fixture
that also has an `app/` directory must render the matched param after
hydration.
Refs #1466
|
||
|
|
8e3438d267 |
fix(app-router): complete default autoscroll page target parity (#1601)
* fix(app-router): complete default autoscroll page target parity App Router page navigations can commit loading shells, final payloads, and superseded payloads out of order. A pending scroll intent that is only keyed by navigation start can be consumed by the wrong committed tree, which makes default page target scrolling and focus timing diverge from Next.js when loading boundaries or rapid navigations are involved. Bind scroll intents to approved render commits, consume only the matching intent from the committed scroll target, and clear hard-navigation failures only for their own intent. Extend the Next.js compat fixture coverage for skipped first children, loading boundary timing, same-page search param changes, focus behavior, horizontal scroll preservation, and stale intent races. * test(app-router): update link navigation scroll intent assertion * fix(app-router): address review — consume scroll intent on no-commit, tighten test - Consume scroll intent before returning no-commit in renderNavigationPayload, preventing the navigateClientSide fallback from scrolling for an abandoned navigation. - Replace waitForTimeout(700) with expect.poll for robust race-condition stability assertion (CI-flake-safe, stronger proof of stability). - Update wrapperless targeting comment to clarify outer function / inner class split. - Add three unit tests covering no-commit cleanup, stale intent rejection, and claim-without-commitId consumption paths. |
||
|
|
0046e0691a |
fix(app-router): preserve encoded prerender params (#1597)
* fix(app-router): preserve encoded prerender params App Router prerendered pages with encoded dynamic params could be bypassed in production and rendered again with decoded route params. This diverged from Next.js for generateStaticParams values like "sticks & stones". The root cause was split across build and startup. The prerender render path only had the encoded URL, so normal route matching decoded the page params. After preserving encoded params for the render, production startup still seeded the ISR cache under the encoded artifact path while live requests lookup cache entries by the runtime-normalized pathname. Carry trusted prerender params through the prerender-only request path, keep decoded params for dynamicParams validation, and seed prerendered App Router artifacts under the normalized runtime cache key while reading files from their encoded output path. * chore(app-router): clean up prerender header internals Reuse the shared record guard for prerender route param parsing and remove the local duplicate. Keep Next.js INTERNAL_HEADERS as the exact upstream list, with vinext-only internal headers tracked separately but stripped by the same request pipeline. This fixes the CI unit failure while preserving the prerender header trust boundary. Factor repeated Node-header conversion and forwarded-protocol handling in the production server to avoid local assertions and duplicate loops. * fix(app-router): scope prerender params to route pattern Bind the internal prerender route-param header to the route pattern that produced it so encoded render params cannot cross a rewrite boundary into a different dynamic route shape. The App Router handler now only applies encoded prerender params when the payload routePattern matches the final matched route. Otherwise it falls back to the decoded params from normal route matching. Tighten the header parser to validate the payload shape with Object.entries/Object.keys and add a regression for a prerendered source route rewritten to a dynamic target route with a different param name. * fix(app-router): verify prerender params match route Require trusted prerender route params to prove both the final route pattern and the final decoded matched params before using their encoded render values. This prevents same-pattern rewrites from carrying stale prerender params into a different concrete route match, while keeping valid prerender renders encoded and static validation decoded. Add handler regression coverage for same-pattern rewrites and helper coverage for catch-all array comparison. * docs(app-router): clarify prerender route-param trust boundary The prerender route-param readers and serializer carried implicit coupling that read as security verification but actually delegated it. A reader new to readTrustedPrerenderRouteParams could assume it verifies the prerender secret, when verification happens once upstream at prod-server's nodeToWebRequest boundary and every downstream read trusts that. The double read-then-reattach in createAppRscHandler also depends on the secret header surviving filterInternalHeaders, with no note of that coupling. Document the trust contract: secret is verified at the outer boundary, downstream reads operate on already-trusted requests gated by VINEXT_PRERENDER=1, and the secret header must stay out of the internal strip list for the second read to succeed. Also document the empty-params to null serialization contract. No behavior change; comments only. |
||
|
|
5b633b0284 |
fix(pages): render masked basePath error routes (#1441)
* fix(pages): render masked basePath error routes
Pages Router route misses under basePath returned generic HTML and masked client navigations to /404 or /_error could fetch the visible as-path instead of the error route. This diverged from Next.js when applications used next/router to preserve a friendly URL while rendering the error component.
The implementation assumed the browser URL and the component route could always collapse to one fetch target. Preserve the masked history URL, fetch the Pages error route as the HTML target, allow its 404 response to hydrate, and render pages/404 for production route misses with the correct status and client module metadata.
* test(pages): keep error navigation coverage out of vitest browser
Vitest integration CI does not install Playwright browsers, so launching Chromium from tests/pages-router.test.ts made the PR fail deterministically even though the upstream deploy harness passed.
Keep the browser-level verification in the upstream deploy-suite run and cover vinext locally at the lower boundaries: production 404 rendering through HTTP and Pages Router masked error-route navigation through the router shim. The generated Pages entry now also shares one SSR manifest module lookup helper between asset tag collection and client module URL resolution.
* fix(prerender): keep custom 404 out of generic page loop
* fix(pages): fall back to _error on route misses
* fix(pages): document the catch-all guard on matchRoute("/404")
The pattern check prevents a dynamic catch-all route (e.g. [...slug]) from
being used as the 404 fallback. Without it, matchRoute can return a
catch-all match and silently hijack the error rendering path.
* fix(pages): preserve error navigation rewrite order
* fix(pages): limit fallback rewrites to route misses
* fix(deploy): skip deferred error rendering for data requests
* fix(pages): preserve ISR response status
* fix(pages): keep module metadata in ISR regeneration
|
||
|
|
5fe9789bbd |
fix(app-router): preserve history index on external state writes (#1600)
* fix(app-router): preserve history index on external state writes External history.pushState and history.replaceState calls preserved vinext interception metadata but dropped the traversal index. That left userland or library shallow history entries without the metadata App Router traversal uses to classify and anchor back/forward state. createExternalHistoryStatePreservingMetadata now preserves vinext navigation metadata as one unit from the current history entry. The shim regression covers object, null, undefined, and index-only external state writes. * test(app-router): seed stale history context through unpatched state Public History API calls now sanitize vinext metadata before delegating. The refresh regression test was seeding stale intercepted context through that public wrapper, so it tested the wrapper boundary instead of the stale internal-state condition. Use the saved original replaceState handle to create the same framework-owned stale state the test is meant to refresh away. |
||
|
|
a0b8ae068a |
fix(draft-mode): keep draft secret out of client defines (#1592)
* fix(draft-mode): keep draft secret out of client defines The draft-mode bypass token was emitted through Vite define, so Vite's client env payload and client chunks could contain the server-only cookie secret when app code referenced that env key. The violated boundary was treating a server runtime credential like a shared compile-time define. Generate one plugin-scoped secret and initialize server entries with it, while next/headers reads only internal server state. Adds regression coverage for dev env exposure, production client bundle leakage, and the built server draft cookie round trip. * test(draft-mode): initialize secret after module reset * fix(draft-mode): scope draft secret to request context Draft mode validation briefly lived in a package-global singleton after removing the client-visible define. That made multiple app instances in the same process able to overwrite each other's validator. Thread the generated app secret through the App Router request and dispatch boundaries, store it on HeadersContext, and make cache bypass validation a pure request-plus-secret check. Add a regression that proves two contexts keep independent draft validators. * fix(draft-mode): guard stale draft controls |
||
|
|
4b4dc8d2ef | fix: handle Pages Router middleware redirects (#1439) | ||
|
|
61cf2e78f5 |
fix(image): emit /_next/image URLs (#1562)
* fix(image): emit /_next/image URLs to match Next.js Closes #1513 The default image loader and optimization endpoint switched from the vinext-specific /_vinext/image path to Next.js's canonical /_next/image. This unblocks the deploy suite tests that import Next.js's expected URL shape (/_next/image?url=...&w=...&q=...). * refactor(image): use IMAGE_OPTIMIZATION_PATH constant at remaining call sites Replace hardcoded "/_next/image" strings in index.ts, app-rsc-handler.ts, and the generated worker entry templates in deploy.ts with the IMAGE_OPTIMIZATION_PATH constant from server/image-optimization, matching the pattern already used in prod-server.ts. Prevents future drift if the path ever changes again. * feat(image): accept both /_next/image and /_vinext/image at the optimizer Add a VINEXT_IMAGE_OPTIMIZATION_PATH constant and an isImageOptimizationPath() helper, then route through every match site (prod-server, dev server passthrough, app RSC handler, generated worker templates, and shipped example workers). Apps that wire image URLs to either prefix now hit the same handler; new URLs are still emitted via IMAGE_OPTIMIZATION_PATH. |
||
|
|
e3b1cde53d |
fix(pages-router): document push/replace Promise<boolean> contract (#1566)
Pages Router `window.next.router.push`/`replace` already resolved to `true` on a successful navigation (and `false` on a hard-navigation fallback) via `performNavigation`, but the public `window.next.router` surface typed both as `(...args: unknown[]) => unknown`, leaving consumers (and the Next.js deploy test suite that `awaits` the result) without a typed boolean to assert on. - Narrow `PagesRouterPublicInstance.push`/`replace` to `(...args: unknown[]) => Promise<boolean>` to match Next.js's `Router.change` contract (router.ts:1206 — `Promise<boolean>`). - Add unit-test regression coverage asserting `window.next.router.push`/`replace` resolve to `true` on success. - Void the `router.push(...)` calls in `tests/e2e/app-router/build-id-navigation.spec.ts` to satisfy the tightened union (App Router push is `void`). Closes #1467 |
||
|
|
62454ef447 |
fix: private use-cache request data (#1445)
* fix use cache private request data * fix private cache nesting guard |
||
|
|
40d23de157 |
fix(next-request): throw canonical relative URL error (#1560)
* fix(next-request): throw canonical relative URL error
Calling `new NextRequest('/foo')` previously bubbled up `Failed to parse
URL from /foo` because the constructor's fallback `new URL(input,
"http://localhost")` masked the real problem. Next.js's NextRequest
runs `validateURL(url)` at the top of its constructor and throws an
error containing `Please use only absolute URLs`. Middleware tests
that assert on this message text now match Next.js.
Closes #1521
* fix(next-request): collapse inline URL validation to validateURL helper
Address bot review nit: the 13-line inline URL validation duplicated the
existing `validateURL` helper at line 215. Replace it with a call to the
helper. Bonus: relative URL rejection now also picks up the
`assertSafeNavigationUrl` guard against `javascript:`/`data:`/`vbscript:`
URIs (defense-in-depth).
|
||
|
|
97c11cdc37 |
fix(pages-router): rename data-vinext-head to data-next-head (#1559)
Pages Router <Head> now emits the canonical `data-next-head=""` attribute matching Next.js, rather than the vinext-specific `data-vinext-head="true"`. This unblocks `_document` head lookups in the deploy suite. |
||
|
|
e121d42ef0 |
fix(middleware): warn when legacy middleware filename is detected (#1561)
Next.js 16 renamed `middleware.{js,ts}` to `proxy.{js,ts}` and emits a
canonical deprecation warning when the legacy filename is detected. vinext
was emitting a custom message that didn't match the wording the Next.js
deprecation-warnings and app-middleware e2e suites assert on.
Update `findMiddlewareFile` to log the exact text Next.js uses (from
`packages/next/src/build/index.ts` adjacent to `MIDDLEWARE_FILENAME`):
The "middleware" file convention is deprecated. Please use "proxy" instead.
...migration codemod and docs link...
The warning still fires only when `middleware.{js,ts}` is found (after
checking `proxy.{js,ts}` first) so behavior is unchanged for projects on
the new convention. Existing test in tests/shims.test.ts is tightened to
assert the canonical wording.
Closes #1455
|
||
|
|
d6bf6413e9 |
feat(pages-router): consume _next/data JSON endpoint from the client (#1412)
* feat(pages-router): consume _next/data JSON endpoint from the client
The server-side `/_next/data/<buildId>/<page>.json` endpoint landed in
#1384, but the vinext client was still fetching the full HTML page on
every navigation and regex-extracting `__NEXT_DATA__` from it. This
wires the client to actually use the JSON endpoint, matching what
Next.js does and avoiding the HTML round trip.
## Client navigation (shims/router.ts)
`navigateClient` is split into two paths:
- `navigateClientData` (new): when `window.__VINEXT_PAGE_LOADERS__` has
a registered code-split loader for the target route AND we have a
buildId, fetch `/_next/data/<buildId>/<page>.json` with
`Accept: application/json` + `x-nextjs-data: 1`. On 200 parse the
envelope, look up the page module via the in-memory loader map (Vite
has already split each page into its own chunk), and re-render.
- `navigateClientHtml`: the previous HTML-fetch + regex-extract logic,
unchanged. Used in dev (where the inline hydration script does not
populate the loader map) and as a fallback for any route not in the
client loader map.
Failure modes — 404, 5xx, network error, malformed JSON, soft
redirect via `x-nextjs-redirect`, missing loader — all queue a hard
navigation (`window.location.href = url`). This is the deploy-skew
safety net: when the server's buildId has rotated, the data endpoint
returns 404 and the client lands on the new build via a full document
load.
## Code-split loader manifest (entries/pages-client-entry.ts)
Vite already code-splits each page into its own chunk via the dynamic
imports in `pageLoaders`. The generated client entry now exposes that
map on `window.__VINEXT_PAGE_LOADERS__` (route pattern → loader thunk),
plus `window.__VINEXT_PAGE_PATTERNS__` (ordered list) and
`window.__VINEXT_APP_LOADER__` (the `_app` thunk, when present). This
replaces Next.js' `_buildManifest.js` indirection — vinext already
generates the equivalent at build time, we just had to expose it.
## Data URL construction (shims/internal/pages-data-url.ts)
New pure helper module mirroring `getAssetPathFromRoute` +
`getDataHref` from Next.js. Handles:
- `/` → `/_next/data/<id>/index.json`
- `/about` → `/_next/data/<id>/about.json`
- `/index` → `/_next/data/<id>/index/index.json` (round-trip)
- `/blog/foo` → `/_next/data/<id>/blog/foo.json`
Also exports `matchPagesPattern` which uses the existing
`matchRoutePattern` to map a URL pathname to a registered route
pattern.
Round-trip tests verify the server's `parseNextDataPathname` agrees
with the client's `buildPagesDataPath` for every shape, and surfaced a
latent server bug: `/_next/data/<id>/index/foo.json` was parsing back
to `/index/foo` instead of `/foo`. Fixed in `pages-data-route.ts` so
the round-trip is now lossless.
## Prefetch (shims/link.tsx + shims/router.ts)
`<Link>` prefetch and `Router.prefetch()` now prefer the data path:
- Inject `<link rel="prefetch" as="fetch" crossorigin href={dataUrl}>`
for the JSON, matching the actual navigation's request shape.
- Invoke the loader thunk to warm the chunk in parallel. Vite's
`import()` caches the result, so the eventual click is a double
cache hit.
When no loader matches (dev, unmapped route), fall back to the legacy
`<link rel="prefetch" as="document">` so the browser at least preloads
the HTML.
Notably this also fixes a long-standing prefetch bug: the previous
Pages branch of `link.tsx`'s prefetch check
(`__NEXT_DATA__.__vinext?.pageModuleUrl`) was only populated by the
dev server's inline hydration script, so prefetch in production was a
no-op.
## Tests
- `tests/pages-data-url.test.ts` (new): 27 tests covering URL
construction and the client/server round trip.
- `tests/shims.test.ts` (+8): JSON-path navigation success, 404
hard-reload (build-skew), `x-nextjs-redirect` soft-redirect handling,
HTML fallback when no loader matches, dynamic param extraction,
malformed JSON hard-reload, prefetch JSON path, prefetch fallback.
- Ports from Next.js: `getDataHref` in
`.nextjs-ref/packages/next/src/client/page-loader.ts:154`,
`fetchNextData` in
`.nextjs-ref/packages/next/src/shared/lib/router/router.ts:498`,
`getAssetPathFromRoute` in
`.nextjs-ref/packages/next/src/shared/lib/router/utils/get-asset-path-from-route.ts`.
Verified: 1393 vitest tests pass across shims, pages-router,
link/navigation, prefetch, data-route, data-url, entry-templates, and
routing.
Closes #1330 follow-up.
* fix(pages-router): drop unused export on PagesPatternMatch
Knip flagged the exported `PagesPatternMatch` type as unused. It is only
referenced as the return type of `matchPagesPattern` in the same file, so
narrow it to a local type.
* fix(pages-router): merge search params into __NEXT_DATA__.query on data-path nav
bigbonk review found that navigateClientData was synthesising
__NEXT_DATA__.query from dynamic route params only, missing the URL search
params. Code reading window.__NEXT_DATA__.query directly (a public Next.js
surface) would see incomplete data after a JSON navigation, even though
useRouter().query happened to still work because getPathnameAndQuery()
re-reads window.location.search.
Parse target.search and merge it into query alongside the route params,
with route params winning on key collision — same merge order as
getPathnameAndQuery() and Next.js itself.
Also fix a misleading comment in pages-client-entry.ts: the patterns list
is already sorted by pagesRouter()/compareRoutes (static → dynamic →
catch-all), not by the router at runtime.
* test(pages-router): e2e regression guard for JSON nav path
Adds a Playwright assertion that a Link click on a hydrated Pages Router
app on Cloudflare Workers fires a request to /_next/data/<buildId>/<page>.json
and does NOT trigger a full HTML reload. If the build pipeline ever stops
exposing __VINEXT_PAGE_LOADERS__ (or the loader map mis-keys), every other
navigation test still passes — they all assert the URL changed and the new
page rendered, neither of which fails when navigateClient silently falls
back to the HTML path. This test fails loudly in that scenario.
* test(pages-router): use hydration timestamp instead of resource-type sniffing
The original assertion 'no document-resource request to /ssr' caught false
positives: <link rel="prefetch" as="document"> can fire on initial paint
for unrelated reasons, and Chromium does not always classify prefetch hints
in a stable way. Swap to checking that __VINEXT_HYDRATED_AT survives the
click — a document reload would reset it, an in-place re-render preserves it.
Combined with the data-URL network assertion, this remains a tight guard on
the JSON nav path without the flake.
* test(pages-router): use window sentinel instead of timestamp comparison
Timestamps are timing-related signals; swap to a window-scoped sentinel
that a document reload would wipe and an in-place re-render preserves.
Same guarantee, no flake.
* fix(pages-router): handle _next/data requests in Cloudflare Workers example
The Node prod-server normalizes /_next/data/<buildId>/<page>.json before
middleware and flags it as a data request so renderPage emits the JSON
envelope. The Cloudflare worker example never did — data requests hit
matchPageRoute which can't match /_next/data/... and returned 404, so
navigateClientData saw the 404 and triggered a hard navigation. The
client-side JSON nav path was effectively dead on Workers.
Mirror the prod-server normalization in the worker: detect the data path
before middleware, validate the buildId, strip it to the page path, and
pass { isDataReq: true } through to renderPage.
Export ./server/pages-data-route from the vinext package so user-written
workers can import the helpers.
* Revert "fix(pages-router): handle _next/data requests in Cloudflare Workers example"
This reverts commit
|
||
|
|
b6fc03cfc3 |
fix(router): preserve default locale for unprefixed root links (#1442)
* fix(router): preserve default locale for unprefixed root links Unprefixed Pages Router URLs represent the default locale, even when the browser's preferred locale has previously been detected as a non-default locale. Treating the ambient runtime locale as authoritative makes a Link from /new to / request /id and returns the wrong page locale. Derive implicit client navigation locale from the current URL first, then domain/default locale state, and render a locale-qualified default root fallback href so non-intercepted root links do not re-enter preferred-locale detection. The regression tests port the relevant Next.js i18n preferred-locale cases at the Link and router boundaries. * refactor(router): share client locale inference The review called out that Link and Router had identical browser locale fallback logic. Keeping that chain duplicated makes future i18n changes easy to apply in one place but miss in the other. Move the URL-first browser locale lookup into a shared shim helper and add SSR coverage for the intentional default-locale root fallback href. |
||
|
|
46ab691094 |
fix(i18n): normalise default-locale paths before route matching (#1409)
Mirrors Next.js's server-side default-locale path normalisation in
`packages/next/src/server/lib/router-utils/resolve-routes.ts` (lines
~250-263): every request that arrives without a locale prefix is
spliced with `/${defaultLocale}` before any `next.config.js`
redirect / rewrite / header rule (or filesystem route) match runs.
Without this, rules like `source: '/:locale/to-sv'` with
`locale: false` failed to match requests for `/to-sv` because there
was no segment for the `:locale` placeholder. After normalisation the
matcher sees `/en/to-sv` and the rule matches with `:locale=en`.
Per the merged PR #1382's description, the issue text's call for a 308
redirect from `/en/page` to `/page` is incorrect: Next.js serves
the same content under both URLs and normalises the path internally.
This change implements that internal normalisation; no redirect.
The new helper lives next to the existing pages i18n helpers so both
routers can use it. Wired into:
- `prod-server.ts` (Pages Router prod)
- `app-rsc-handler.ts` (App Router dev + prod)
- `index.ts` (Pages Router dev plugin)
- `deploy.ts` (Cloudflare Workers entry template)
The domain-mapped default locale (`i18n.domains[].defaultLocale`) is
preferred over the global default when the inbound host matches,
matching Next.js's `domainLocale.defaultLocale` branch in
resolve-routes.ts. `/_next/*` and `/__vinext/*` paths are excluded
to mirror Next.js's exception for build assets and prerender manifests.
The fallback "retain the original unprefixed source" branch in
`applyLocaleToRoutes` is intentionally kept as belt-and-braces: the
new normalisation pass renders it dead-code for ordinary requests, but
it harmlessly preserves backwards compatibility with any matcher
caller that still passes raw, unnormalised pathnames.
Refs #1336 (item 4)
|
||
|
|
8df248863d | fix: match Next metadata image route behavior (#1436) | ||
|
|
512be7f25c |
fix(middleware): preserve route priority on rewrite target resolution (#1394)
* fix(middleware): preserve route priority on rewrite target resolution When `trailingSlash: true` is set in next.config.js, a request like `/rewrite-1/` was matched against the next.config.js rewrite source `/rewrite-1` literally, including the trailing slash. The match failed, so the request fell through to the dynamic `[id]` route and rendered the dynamic page with `id="rewrite-1"` instead of being rewritten to the static target. Strip a single trailing slash from the incoming pathname before matching against config rewrite/redirect/header sources, matching Next.js's `source + '(/)?'` behavior under `trailingSlash: true`. The source pattern itself is preserved so catch-all (`:param*`, `:param+`) and root (`/`) semantics are unaffected. Refs #1331 * address bonk feedback: locale-static path, comment wording, tighter tests - Extract `stripTrailingSlashForConfigMatch` and call it from `matchRedirect` so the locale-static fast path (Map.get on the pathname) also honors trailing slashes — previously a pure locale-static redirect rule could miss when `trailingSlash: true`. - Clarify the comment to reflect that the strip is unconditional defense-in-depth rather than a faithful port of Next.js's conditional `(/)?` suffix. - Disambiguate the pages-router integration tests by asserting on `id="home"` / `id="ssr"` so the assertion can't pass on the wrong page (both pages render "Hello World"). - Add direct unit tests for `matchConfigPattern` trailing-slash behavior covering exact, :param, regex-group, root, and catch-all cases. * extend trailing-slash normalization to matchHeaders Bonk flagged that matchHeaders runs its own compiled regex path that doesn't go through matchConfigPattern, so a header source like `/api/:path*` would miss `/api/foo/` under `trailingSlash: true`. Normalize once at the top of matchHeaders the same way matchRedirect already does, and add a regression test. |
||
|
|
f0f3e35947 |
fix(trailing-slash): enforce config in routing and middleware redirects (#1395)
Plumb `trailingSlash` from the resolved next.config into NextURL via NextRequest, so `NextResponse.redirect(request.nextUrl)` (and any other stringification of a NextURL) emits a Location header that matches the user's slash policy. Mirrors Next.js's `formatNextPathnameInfo`. Pre-route trailing-slash normalization (e.g. `/foo` -> `/foo/` when trailingSlash:true) already runs at every entry point via `normalizeTrailingSlash` in request-pipeline. This commit closes the remaining gap by making redirect Locations produced from inside middleware (the common one-hop `request.nextUrl` redirect pattern in the Next.js middleware-trailing-slash fixture) carry the slash too. Refs #1332. Sub-problem 3 (shallow navigation triggering a server roundtrip) is a separate Pages Router client-side concern and deferred as follow-up. |
||
|
|
818093b7d9 |
fix(i18n): honor locale: false on rewrites/redirects + default-locale redirect (#1382)
* fix(i18n): honor locale: false on rewrites/redirects
When a rewrite or redirect carries `locale: false`, Next.js leaves the
source pattern untouched so user-supplied `:locale` segments can capture
the locale prefix themselves. Without `locale: false`, Next.js prepends
an internal locale alternation so the rule matches every locale-prefixed
URL automatically.
Vinext previously ignored the flag entirely and never injected the
locale alternation, so:
- rewrites with `locale: false` on a source like
`/:locale/rewrite-files/:path*` failed because the underlying
matcher could not handle a non-trailing named param combined with a
trailing catch-all;
- plain rewrites/redirects with i18n configured silently failed to
match locale-prefixed paths.
This change:
1. Adds `locale?: false` (and `basePath?: false` for forward
compatibility) to NextRedirect/NextRewrite.
2. Fixes matchConfigPattern to use the regex branch whenever a pattern
has multiple named params and a catch-all.
3. Adds applyLocaleToRoutes mirroring Next.js's processRoutes — emits
`/:nextInternalLocale(en|sv|nl)/...` and (for redirects) per
default-locale literal variants while skipping rules flagged with
`locale: false`. Retains the original unprefixed source as a
fallback so default-locale requests that arrive without a prefix
still match (Next.js handles this upstream via request path
normalisation; doing it here avoids a server-wide change).
Ported tests from
test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts
Refs #1336 — items 2, 3, 4 remain as follow-ups.
* fix(i18n): address Bonk review feedback
- Keep the locale-capture variants emitted by applyLocaleToRoutes on the
redirect index fast path. `_LOCALE_STATIC_RE` now matches both the
optional `/:locale(en|fr)?/foo` user-written form and the mandatory
`/:nextInternalLocale(en|fr)/foo` form. Each entry records whether the
locale segment is optional; the no-locale-prefix branch only fires for
optional entries so mandatory variants still require a prefix.
- Thread `trailingSlash` through `applyLocaleToRoutes` so the root source
is preserved (`/:nextInternalLocale(en|fr)/`) when trailingSlash is
configured, matching Next.js processRoutes.
- Promote the dynamic import in next-config.ts to a static import — the
module is already loaded for isExternalUrl.
- Drop the unnecessary `new Set([...])` dedup and document that the
defaultLocales list will grow once domain-locale support lands.
|
||
|
|
517c2bde19 |
feat(pages-router): expose window.next.router on hydration (#1400)
* feat(pages-router): expose window.next.router on hydration
Promote next/router from a lazy dynamic import inside hydrate() to a
static side-effect import at the top of the generated client entry, so
window.next = { version, router, ... } is published before any test
harness or third-party script reads it.
Without this, the Next.js deploy test suite — which drives client-side
navigations via `browser.eval('next.router.push(...)')` — crashes with
`TypeError: Cannot read properties of undefined (reading 'router')` (~80
test failures across middleware-general, middleware-rewrites,
trailing-slashes, basepath, with-router, and ignore-invalid-popstateevent).
Mirrors Next.js's bootstrap order in
.nextjs-ref/packages/next/src/client/next.ts (line 5–13), which
statically imports the router from './' and sets window.next as a
top-level side effect before initialize/hydrate.
Closes #1329
* feat(router): expose state fields as live getters on Router singleton
window.next.router must expose `pathname`, `route`, `query`, `asPath`,
`basePath`, `locale`, `locales`, `defaultLocale`, `domainLocales`,
`isReady`, `isPreview`, and `isFallback` as live properties — not just
the navigation methods. The Next.js deploy test suite drives navigations
via `browser.eval('next.router.push(...)')` and then asserts on
`browser.eval('next.router.pathname')` to verify the navigation
completed; without the field getters the assertion reads undefined.
Mirrors Next.js's `singletonRouter` shape in
.nextjs-ref/packages/next/src/client/router.ts (lines 32-47), which
uses Object.defineProperty for urlPropertyFields so reads forward to
the active router state.
Strengthens the existing shims.test.ts coverage so future regressions
of the field surface fail at unit-test time.
|
||
|
|
7771099d1d |
fix(pages-router): guard navigation shim against SSR (#1378)
`performNavigation()` and the `Router` singleton's `back`/`reload`/ `prefetch`/`beforePopState` methods referenced browser globals (`window`, `document`) unconditionally. Invoking `router.push()` from a Pages Router component during SSR or prerender (`VINEXT_PRERENDER=1`) therefore crashed with `ReferenceError: window is not defined` and took down the request pipeline. Mirror Next.js's `ServerRouter` (packages/next/src/server/render.tsx), where every navigation method synchronously throws "No router instance found". The throw happens before any browser global is accessed, so the failure surfaces as a normal render error instead of a ReferenceError — and the documented message + docs link keeps userland error handling intact. Closes #1353 |
||
|
|
fa1392616d |
fix(pages-router): preserve i18n client root navigation (#1323)
* fix(pages-router): preserve i18n client root navigation Pages Router client navigation fetched the unprefixed root URL even when the transition resolved to the default locale. That let root Accept-Language detection redirect a link click from /new to the preferred locale path, diverging from Next.js. Use the active locale for implicit Link and Router transitions, fetch default-locale root HTML through a locale-qualified internal URL while preserving the visible unprefixed URL, and parse i18n __NEXT_DATA__ scripts that append locale globals. * fix(pages-router): preserve i18n client root navigation Pages Router client navigation fetched the unprefixed root URL even when the transition resolved to the default locale. That let root Accept-Language detection redirect a link click from /new to the preferred locale path, diverging from Next.js. Use the active locale for implicit Link and Router transitions, fetch default-locale root HTML through a locale-qualified internal URL while preserving the visible unprefixed URL, and parse i18n __NEXT_DATA__ scripts that append locale globals. * fix(pages-router): validate next data parsing * fix(pages-router): preserve link locale transitions * fix(pages-router): preserve popstate locale fetch * chore(pages-router): address locale review nits |
||
|
|
0d8d3e3c60 |
refactor(router): quarantine navigation runtime topology (#1322)
* refactor(router): quarantine navigation runtime topology * fix(router): restore manifest-backed interception runtime App Router browser navigations lost first-hop intercepted route context after snapshot topology stopped acting as fallback proof. That made /feed to /photos/:id request the direct page payload instead of the declared modal payload. The violated invariant was that route topology must come from the manifest, while snapshots only carry runtime proof. Resolve first-hop interception from the manifest, move RSC bootstrap streams onto the symbol-backed runtime, teach prerender extraction that script shape, and harden the runtime contract around route manifest and RSC data. Coverage includes runtime seam tests, prerender extraction tests, planner/browser navigation tests, package rebuild, and the app-router intercepted routes E2E slice. * fix(router): narrow prerender RSC script parsing The prerender extractor accepted any script starting with the symbol-runtime bootstrap expression, then parsed it as though it had the exact RSC chunk-push prefix. That was harmless at the current head, but it made future runtime scripts a foot-gun. Only the exact runtime chunk prefix and exact done marker are now accepted. A focused extractor test covers non-chunk runtime scripts that share the bootstrap expression prefix. * fix(router): address navigation runtime review * fix(router): resolve navigation runtime review threads |
||
|
|
afc549d624 |
fix(router): normalize trailing slash parity (#1316)
Trailing slash handling treated every non-api path the same on server redirects, so trailingSlash:true added slashes to file-looking catch-all routes and Pages Router imperative navigation skipped the client canonicalization path used by Link. Next.js splits the invariant between route-manifest redirects and client resolveHref normalization: file-looking paths lose a trailing slash, non-file paths gain one, and queries stay attached to the canonical pathname. Share the server redirect decision across Pages Router runtimes and apply the same client helper in Router.push and Router.replace. Adds focused regressions for catch-all dot segments, query preservation, .well-known exclusion, and Pages Router history writes. |
||
|
|
54e271ddc9 |
fix(cache-runtime): attach inner "use cache" call site as cause of nested-dynamic error (#1289)
* fix(cache-runtime): attach inner "use cache" call site as cause of nested-dynamic error Ports Next.js PR #93707 to vinext. When a nested "use cache" propagates a dynamic cache life (revalidate: 0 or expire < 5 minutes) to a parent without an explicit cacheLife(), the error now attaches the inner invocation as . Changes: - Add NestedDynamicUseCacheError class (captured eagerly at inner call site) - Track hasExplicitRevalidate / hasExplicitExpire on CacheContext - Propagate dynamicNestedCacheError via ??= (first child wins) - Throw with when outer lacks explicit cacheLife for the relevant field - Update error messages to consistently backtick "use cache" - Add unit tests covering revalidate:0, short expire, explicit outer, first-child-wins Refs: #1194 Ported from: https://github.com/vercel/next.js/pull/93707 * docs(cache-runtime): document nested-dynamic error propagation invariants Addresses bigbonk review feedback on #1289: - Guard `Error.captureStackTrace` with a typeof check for non-V8 runtime robustness. The constructor already captures a stack; this call only trims the constructor frame. - Document that propagating the inner's resolved life into the parent's `lifeConfigs` is load-bearing: minimum-wins must reflect the inner's dynamic values for the outer's nested-dynamic detection to fire, and the outer's explicit `cacheLife()` correctly suppresses the throw by pushing the resolved values above the dynamic thresholds. Matches Next.js `propagateCacheLifeAndTagsToRevalidateStore`. - Clarify that `hasExplicitRevalidate` / `hasExplicitExpire` flags really mean "cacheLife() was called and the resolved config includes this field", not "the user explicitly passed this field" — matches upstream which tracks the flag at the work unit store level. - Strengthen the first-child-wins test to assert the outer error message contains "revalidate" (not "expire"), rigorously verifying that `dynamicNestedCacheError ??=` preserves the first dynamic child's eager error as the cause. * fix(cache-runtime): gate nested-dynamic throw on prerender/dev to match Next.js Addresses bigbonk review feedback on #1289 (round 2): - Previously the nested-dynamic-cache error threw unconditionally, even during normal production dynamic SSR. The error messages say "not allowed during prerendering", which is misleading outside the prerender phase. Next.js gates the equivalent check on the work unit type being `prerender` (or `request` in development) — see use-cache-wrapper.ts cases 'prerender'/'request' at the read site. - Gate the throw on `process.env.VINEXT_PRERENDER === "1"` (set by build/prerender.ts during the prerender phase) or NODE_ENV === "development". Production runtime SSR no longer throws; the outer function still executes and its result simply isn't cached (minimum-wins resolves its effective revalidate to 0). - Add a regression test verifying no-throw behavior under production runtime conditions (no VINEXT_PRERENDER, NODE_ENV=production). - Document caller paths (shared MISS, private, dev) on `runCachedFunctionWithContext` — all three reach this helper and propagate lifeConfigs / dynamicNestedCacheError up to the parent, matching Next.js's `propagateCacheEntryMetadata`. Request-scoped recording uses minimum-wins so inner-vs-outer order doesn't matter. - Add a test documenting the potentially surprising minimum-wins semantics: with `cacheLife({ revalidate: 60 })` on the outer and `cacheLife({ revalidate: 0 })` on the inner, the throw is suppressed but the outer's effective revalidate resolves to 0, so the outer's result isn't cached and the function executes on every call. Matches Next.js `propagateCacheLifeAndTagsToRevalidateStore`. - Wrap prerender-gated tests in a `withPrerenderFlag` helper that sets/restores VINEXT_PRERENDER around each test. * fix(cache-runtime): make nested-dynamic error message context-aware; pin dev/prod gate Addresses bigbonk review feedback on #1289 (round 3): - Make the nested-dynamic error messages context-aware: emit "in development" when NODE_ENV=development, "during prerendering" otherwise. The previous unconditional "during prerendering" wording was misleading when the gate fired in dev SSR. Done via two small message-builder functions instead of constants. - Add a dev-mode test ("does throw in development mode even without VINEXT_PRERENDER set") that pins down the other side of the gate. Without this, Vitest's default NODE_ENV=test would mask a regression that broke the gate to e.g. `!== "production"` instead of `=== "development"`. Together with the existing production test, both branches of the gate are now covered. - Document the sequencing invariant between the inner's `parentCtx.lifeConfigs.push(effectiveLife)` and the outer's `resolveCacheLife(ctx.lifeConfigs)`. The correctness depends on the inner's await completing (including its post-execution propagation) before the outer's resolveCacheLife runs. A future refactor away from `await` (e.g. Promise.all with short-circuit) would silently break this and produce stale minimum-wins results. - Document the eager-Error allocation overhead trade-off: matches Next.js (captured eagerly so `cause` points at the original call site), with the lazy-capture optimization noted for future profiling. Verified: tests/shims.test.ts (918 tests), cache + ISR + fetch-cache suites, and full `vp check` all clean. * docs(cache-runtime): clarify gate priority, effectiveLife semantics, and per-field flag note Addresses bigbonk review feedback on #1289 (round 4): - Prioritize VINEXT_PRERENDER over NODE_ENV=development in nestedCacheContextPhrase. If the prerender flag is set, the user is prerendering regardless of NODE_ENV (e.g. a dev-config prerender), so "during prerendering" is the accurate phrase. - Document the semantic difference between checking the outer's merged effectiveLife.revalidate vs the inner's entry metadata (as Next.js does). Behavior is observably equivalent in all cases due to the hasExplicit* guards; we use effectiveLife because vinext doesn't model a CacheResultMetadata type. - Extend the hasExplicit* comment in cache.ts to clarify that the `!== undefined` checks are effectively unconditional (default profile always provides both fields). They remain as defensive guards; per- field suppression would require inspecting the raw user input and would diverge from Next.js semantics, so it's noted as a deliberate design change rather than an incidental one. Bonk's remaining comments are informational: - Comment 2 ("lifeConfigs.push is a behavioral change"): the PR description already calls this out; no code change. - Comment 4 ("NODE_ENV=test gate coverage gap"): explicitly flagged as not blocking; the dev + prod test pair already pins both branches. Verified: tests/shims.test.ts (918 tests) and full `vp check` clean. * docs(cache-runtime): correct hasExplicit guard explanation; note parallel-call invariant Addresses bigbonk review feedback on #1289 (round 5): - Fix incorrect minimum-wins explanation in the throw block comment. The previous wording claimed minimum-wins "drops" the inner's values when the outer has an explicit cacheLife() — that's wrong. Minimum- wins takes Math.min(60, 0) = 0, so the threshold check still evaluates true. What actually suppresses the throw is the `!ctx.hasExplicitRevalidate` / `!ctx.hasExplicitExpire` guard. Future maintainers reading the old comment might have concluded the hasExplicit* guards were redundant; the corrected wording makes clear they are load-bearing. - Also corrected the same misconception in the comment above the `parentCtx.lifeConfigs.push` block ("the outer only suppresses the throw when its own explicit cacheLife() pushed the effective values above the dynamic thresholds"). Replaced with the accurate two-step description: lifeConfigs.push makes the threshold checks possible; hasExplicit* guards independently decide suppression. - Add a sentence to the sequencing-invariant comment noting it holds for both sequential (`await innerA(); await innerB()`) and parallel (`await Promise.all([innerA(), innerB()])`) inner call patterns, since the outer's `await cacheContextStorage.run(...)` only resolves after fn's returned promise settles. - Updated PR description to call out `parentCtx.lifeConfigs.push` as a standalone behavioral change in production (not just during prerender/dev), separate from the error-cause feature. Linked to the minimum-wins documenting test that pins this behavior. Verified: tests/shims.test.ts (918 tests) and `vp check` clean. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
558cec0328 |
fix(shims/script): preload <script> sources during SSR via ReactDOM.preload (#1309)
Mirror Next.js's App Router behavior at
.nextjs-ref/packages/next/src/client/script.tsx:298-376: when SSR-rendering
`<Script src={...} />` with `strategy="afterInteractive"` (the default)
or `strategy="beforeInteractive"`, call `ReactDOM.preload(src, { as: 'script', ... })`
so React Float hoists `<link rel="preload" as="script" href={src} />` into
`<head>`. The script is then fetched in parallel with the streamed HTML.
Before this change, the SSR branch of the Script shim only emitted a
`<script>` tag for `beforeInteractive` and returned `null` for every other
strategy, with no preload. The matching Next.js fixture test
test/e2e/app-dir/app-esm-js/index.test.ts ("should be able to render
nextjs api in app router") asserts:
expect($('head link[href="/test-ext.js"]').length).toBe(1)
expect($('head link[href="/test.js"]').length).toBe(1)
…which fails today because no preload links are emitted.
The existing Image shim already uses this exact React Float pattern
(packages/vinext/src/shims/image.tsx:285-286), so the SSR pipeline
already supports Float hoisting.
Tests:
- Updated existing Script SSR tests in tests/script.test.ts and
tests/shims.test.ts that asserted afterInteractive/default rendered
empty — they now assert the preload <link> is emitted (no <script>
tag), matching Next.js.
- Added tests/nextjs-compat/script-preload.test.ts that ports the
app-esm-js fixture's preload assertions against the app-basic
/script-nonce page (which already exercises afterInteractive and
beforeInteractive strategies via the existing fixture).
- crossOrigin handling matches React's type contract
("anonymous" | "use-credentials" | undefined). React normalises
"anonymous" to `crossorigin=""` in HTML; tests accept both forms.
All shim / script / page / document / nonce tests pass (1151 tests).
|
||
|
|
aed79d46b0 |
test: regression tests for issue #1196 (catch-all route params with basePath + rewrites + middleware) (#1291)
* test: add regression tests for issue #1196 (catch-all route params with basePath + rewrites + middleware) Adds integration and unit tests to verify that catch-all route params are preserved when basePath, rewrites, and middleware are combined. - Integration test in pages-router.test.ts: checks server-rendered __NEXT_DATA__ contains correct page and query for a catch-all route. - Unit test in shims.test.ts: verifies client-side router.query stays intact for a catch-all page under basePath. Both tests are ported from the Next.js test suite and target the regression described in GitHub issue #1196. * test: address review feedback on #1196 regression tests - Use {.*?} regex for consistency with other __NEXT_DATA__ extractions - Drop unused { appRouter: false } option (vinext auto-detects) - Rename shims test to describe what it actually verifies (initial state) - Move vi.resetModules() after window restore for clean teardown ordering * test: add real client-nav test for #1196 catch-all router.query Adds a Router.push() test that mocks fetch and exercises the actual client-side navigation code path: performNavigation → pushState → navigateClient → fetch. The mocked fetch handler captures router state mid-navigation via a Probe component, then asserts router.query.path reflects the destination URL segments after basePath is stripped. This addresses the bonk review feedback that the previous tests only verified initial hydration state and didn't exercise client navigation. The new test also uses the previously-unused buildNavHtml `query` parameter, addressing the dead-signature concern. vinext's navigateClient doesn't construct _next/data/... URLs the way Next.js does, so it doesn't have the architectural pattern that causes #1196 in Next.js. But the route-param extraction path that ultimately produces router.query must still strip basePath from the resolved pathname before matching the catch-all pattern — and that's what this test verifies. --------- Co-authored-by: opencode <noreply@opencode.ai> |
||
|
|
7283e44beb |
fix(router): keep next/router import free of popstate side effects (#1294)
* fix(router): keep next/router import free of popstate side effects Importing next/router currently installs the Pages Router popstate listener as module-level browser state. That listener can be pulled into App Router client bundles by compatibility imports, where it races the App Router history runtime and can turn back/forward traversal into a document navigation. Move Pages Router popstate installation behind an internal vinext runtime module that only Pages Router entrypoints call after hydration setup. Keep the existing popstate decision logic in the router shim, but make global listener ownership explicit at the Pages boundary. Add unit coverage for side-effect-free next/router imports and an App Router e2e regression that imports next/router, traverses history, and asserts no document request occurs. * refactor(router): remove redundant beforePopState assertion The Pages Router popstate handler already narrows the beforePopState callback before invoking it. Call the narrowed function directly so the production router code does not carry an unnecessary type assertion. |
||
|
|
ff46514563 |
fix(link): preserve native URI scheme navigation (#1268)
* fix(link): preserve native URI scheme navigation Link treated only http, https, and protocol-relative hrefs as browser-owned. Native schemes such as mailto:, tel:, and sms: were therefore locale-prefixed, eligible for prefetch normalization, and intercepted by client navigation. The URL boundary now mirrors Next.js absolute URL scheme classification while preserving vinext's protocol-relative handling. Link rendering, click interception, relative resolution, and prefetch normalization all share that classification so non-local schemes stay with the browser while same-origin absolute URLs remain routable. Adds focused regression coverage for native scheme rendering, click handling, and prefetch decisions. * refactor(router): reuse shared URL scheme detection Router and navigation shims already had local external URL predicates with the same browser-owned URL semantics now used by Link. Reuse the shared url-utils predicate for Pages Router locale handling, Pages Router external detection, App Router external detection, and App Router prefetch normalization. Add focused shim coverage for native URI schemes through the Pages Router helpers. * test(link): add hasAttribute stub to native URI scheme click event The merge of main introduced a download-attribute check in handleClick that runs before the native-URI early return, so test click events must provide currentTarget.hasAttribute. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
fdf3029ea4 |
fix(router): share pages useRouter state through context (#1271)
Pages Router useRouter previously owned a hook-local router snapshot and subscribed each hook call to vinext:navigate. That diverged from Next.js RouterContext semantics and made mounted hook count scale global listeners. Move the reactive snapshot into the Pages Router provider, make next/router consume RouterContext like Next.js, and keep singleton navigation methods as the shared command surface. Add regression coverage for context reads, missing context errors, and per-hook listener scaling. Co-authored-by: James <james@eli.cx> |
||
|
|
be9f338306 |
fix(router): derive shallow dynamic params from the URL (#1272)
* fix(router): derive shallow dynamic params from the URL Pages Router shallow navigation across one dynamic route template updated the browser URL but kept router.query params from the original __NEXT_DATA__ payload. That left client state stale after transitions such as /posts/42 to /posts/43. The router now matches the current pathname against the hydrated route pattern before falling back to __NEXT_DATA__.query. This preserves shallow data-fetch skipping while keeping dynamic params aligned with the visible URL. Tests cover the router shim boundary and the browser-visible Pages Router shallow transition. * refactor(router): reuse route-pattern matching for shallow params The shallow dynamic params fix originally duplicated route-pattern parsing inside the router shim. Reuse the existing routePatternParts and matchRoutePattern helpers instead, keeping the __NEXT_DATA__ fallback behavior unchanged. --------- Co-authored-by: James <james@eli.cx> |
||
|
|
4988436b6c |
fix(app-router): preserve history metadata for external state updates (#1259)
External history.pushState and history.replaceState currently store caller data as-is. That drops vinext's App Router previousNextUrl marker when apps perform shallow URL updates inside an intercepted route, so later traversal can lose the interception source. The History API wrappers now copy the current App Router metadata onto caller-provided state before delegating to the browser history method. A focused shim regression covers object, null, and undefined caller state against the same contract Next.js preserves. |
||
|
|
6663dd10d8 |
fix(router): decode hash scroll targets (#1255)
* fix(router): decode hash scroll targets Hash-only client navigation looked up raw URI fragments directly. That meant ids such as "hello world" and browser-compatible named anchors were skipped when links used encoded fragments or legacy anchors. The router shims now share the decoded id, named-anchor fallback, and top-fragment behavior already used by the app browser popstate path. Regression coverage exercises hash-only Pages Router pushes for decoded ids and name fallback. * fix(router): respect scroll false for hash navigation Match Next.js Pages Router behavior by gating hash target scrolling behind the scroll option for both hash-only and hash-bearing navigations. Add regression coverage for hash-only Router.push with scroll disabled. |