diff --git a/.changeset/metal-weeks-lose.md b/.changeset/metal-weeks-lose.md new file mode 100644 index 00000000000..90809029e81 --- /dev/null +++ b/.changeset/metal-weeks-lose.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': patch +--- + +fix: `routeLoader$` not re-running during SPA navigation between URLs that match the same catchall route diff --git a/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.ssg.html b/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.ssg.html index 59903ec87e4..4a7fa482434 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.ssg.html +++ b/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.ssg.html @@ -1,3 +1,3 @@ Qwik Router SSG Snapshot

SSG Snapshot Fixture

routeLoader$ | useResource$ | useSignal

{"heading":"SSG Snapshot Fixture","stats":[2,3,5,8],"tags":["routeLoader$","useResource$","useSignal"],"profile":{"name":"router-state","status":"stable"}}
{"total":18,"selectedCount":2,"clicks":2}
{"headline":"router-state:stable","selected":["alpha","beta"],"weighted":[2,6,15,32],"summary":{"total":18,"selectedCount":2,"clicks":2}}
[state omitted] [vnode map omitted] - \ No newline at end of file + \ No newline at end of file diff --git a/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.state.txt b/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.state.txt index ce9ef96a7a9..6950ce324a2 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.state.txt +++ b/e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot/expected.state.txt @@ -26,10 +26,83 @@ ] ] {number} 1 - Constant undefined + Map [ + RootRef [omitted] + Set [ + RootRef [omitted] + ] + {string} "pagePathname" + Set [ + EffectSubscription [ + AsyncSignal [ + QRL "[omitted]" + Set [ + EffectSubscription [ + VNode "12AAA" + {string} ":" + Constant null + ] + EffectSubscription [ + ComputedSignal [ + QRL "[omitted]" + Set [ + RootRef [omitted] + ] + Object [ + {string} "total" + {number} 18 + {string} "selectedCount" + {number} 2 + {string} "clicks" + {number} 2 + ] + ] + {string} "." + Constant null + ] + EffectSubscription [ + WrappedSignal [ + {number} 1 + Array [ + RootRef [omitted] + ] + {number} 7 + VNode "14A" + EffectSubscription [ + RootRef [omitted] + {string} "." + Constant null + ] + ] + {string} "." + Constant null + ] + ] + Constant undefined + Constant undefined + Constant undefined + {number} 256 + Constant NEEDS_COMPUTATION + {number} 120000 + ] + {string} ":" + Constant null + ] + ] + {string} "pageSearch" + Set [ + RootRef [omitted] + ] + ] Store [ RootRef [omitted] {number} 1 + Map [ + RootRef [omitted] + Set [ + RootRef [omitted] + ] + ] ] ] RootRef [omitted] @@ -271,7 +344,7 @@ Set [ EffectSubscription [ WrappedSignal [ - {number} 1 + {number} 2 Array [ RootRef [omitted] ] @@ -300,123 +373,38 @@ 22 {string} "s_TG0a9Yb7gLI" 23 RootRef [omitted] 24 RootRef [omitted] -25 QRL "[omitted]" -26 Map [ - {string} ":" - RootRef [omitted] -] -27 {number} 1 -28 Array [ - RootRef [omitted] -] +25 RootRef [omitted] +26 RootRef [omitted] +27 Object 0 +28 {string} "__qwik_route_loader_value__qO14f4pG8tQ" 29 RootRef [omitted] -30 RootRef [omitted] -31 RootRef [omitted] -32 RootRef [omitted] -33 RootRef [omitted] -34 RootRef [omitted] -35 RootRef [omitted] -36 RootRef [omitted] -37 RootRef [omitted] -38 RootRef [omitted] -39 RootRef [omitted] -40 RootRef [omitted] -41 RootRef [omitted] -42 RootRef [omitted] -43 RootRef [omitted] -44 RootRef [omitted] -45 Signal [ - Object [ - RootRef [omitted] - {number} 200 - {string} "statusMessage" - Constant undefined - {string} "action" - Constant undefined - {string} "actionResult" - Constant undefined - {string} "formData" - ] +30 Array [] +31 Object [ RootRef [omitted] -] -46 RootRef [omitted] -47 RootRef [omitted] -48 Object [ - {string} "__qwik_route_loader_value__qO14f4pG8tQ" Constant _UNINITIALIZED RootRef [omitted] - AsyncSignal [ - QRL "[omitted]" - Set [ - EffectSubscription [ - VNode "12AAA" - {string} ":" - Constant null - ] - EffectSubscription [ - ComputedSignal [ - QRL "[omitted]" - Set [ - RootRef [omitted] - ] - Object [ - {string} "total" - {number} 18 - {string} "selectedCount" - {number} 2 - {string} "clicks" - {number} 2 - ] - ] - {string} "." - Constant null - ] - EffectSubscription [ - WrappedSignal [ - {number} 2 - Array [ - RootRef [omitted] - ] - {number} 7 - VNode "14A" - EffectSubscription [ - RootRef [omitted] - {string} "." - Constant null - ] - ] - {string} "." - Constant null - ] - ] - Constant undefined - Constant undefined - Constant undefined - {number} 256 - Constant NEEDS_COMPUTATION - {number} 120000 - ] + RootRef [omitted] ] -49 Constant undefined -50 {string} "s_1sKEZ9Pg1ow" -51 RootRef [omitted] -52 QRL "[omitted]" -53 Map [ +32 {string} "q-xxxxxxxx.js" +33 {string} "s_0vpftJHSMyo" +34 RootRef [omitted] +35 QRL "[omitted]" +36 Map [ {string} ":" RootRef [omitted] ] -54 {number} 2 -55 Array [ +37 {number} 2 +38 Array [ RootRef [omitted] RootRef [omitted] ] -56 QRL "[omitted]" -57 Map [ +39 QRL "[omitted]" +40 Map [ {string} ":" RootRef [omitted] ] -58 {number} 4 -59 Array [ +41 {number} 4 +42 Array [ RootRef [omitted] RootRef [omitted] RootRef [omitted] @@ -542,45 +530,7 @@ Constant false ] ] -60 RootRef [omitted] -61 {string} "s_yw17chooOac" -62 RootRef [omitted] -63 Constant undefined -64 RootRef [omitted] -65 RootRef [omitted] -66 Object [ - {string} "url" - RootRef [omitted] - {string} "params" - Object 0 - {string} "isNavigating" - Constant false - {string} "prevUrl" -] -67 {string} "s_XpalYii770E" -68 RootRef [omitted] -69 RootRef [omitted] -70 {string} "s_69B0DK0eZJc" -71 RootRef [omitted] -72 RootRef [omitted] -73 RootRef [omitted] -74 Map [ - RootRef [omitted] - RootRef [omitted] -] -75 {string} "q-xxxxxxxx.js" -76 {string} "s_9CrWYOoCpgY" -77 RootRef [omitted] -78 RootRef [omitted] -79 RootRef [omitted] -80 RootRef [omitted] -81 Object 0 -82 RootRef [omitted] -83 Array [] -84 {string} "q-xxxxxxxx.js" -85 {string} "s_0vpftJHSMyo" -86 RootRef [omitted] -87 Signal [ +43 Signal [ {number} 2 EffectSubscription [ RootRef [omitted] @@ -589,7 +539,7 @@ ] RootRef [omitted] ] -88 Store [ +44 Store [ Object [ {string} "expanded" Constant true @@ -624,27 +574,100 @@ ] ] ] -89 RootRef [omitted] -90 {string} "q-xxxxxxxx.js" -91 {string} "s_cgSl2l0RC14" -92 RootRef [omitted] -93 RootRef [omitted] -94 Map [ +45 RootRef [omitted] +46 {string} "q-xxxxxxxx.js" +47 {string} "s_cgSl2l0RC14" +48 RootRef [omitted] +49 RootRef [omitted] +50 Map [ {string} "." RootRef [omitted] ] -95 {string} "s_QwONcWD5gIg" -96 RootRef [omitted] -97 {string} "q-xxxxxxxx.js" -98 {string} "s_LqnNyU1Iy8c" -99 RootRef [omitted] -100 QRL "[omitted]" -101 Object [ +51 RootRef [omitted] +52 RootRef [omitted] +53 RootRef [omitted] +54 QRL "[omitted]" +55 Map [ + {string} ":" + RootRef [omitted] +] +56 {number} 1 +57 Array [ + RootRef [omitted] +] +58 RootRef [omitted] +59 RootRef [omitted] +60 RootRef [omitted] +61 RootRef [omitted] +62 RootRef [omitted] +63 RootRef [omitted] +64 RootRef [omitted] +65 RootRef [omitted] +66 RootRef [omitted] +67 RootRef [omitted] +68 RootRef [omitted] +69 RootRef [omitted] +70 RootRef [omitted] +71 RootRef [omitted] +72 RootRef [omitted] +73 RootRef [omitted] +74 Signal [ + Object [ + RootRef [omitted] + {number} 200 + {string} "statusMessage" + Constant undefined + {string} "action" + Constant undefined + {string} "actionResult" + Constant undefined + {string} "formData" + ] + RootRef [omitted] +] +75 RootRef [omitted] +76 RootRef [omitted] +77 Constant undefined +78 {string} "s_1sKEZ9Pg1ow" +79 RootRef [omitted] +80 RootRef [omitted] +81 {string} "s_yw17chooOac" +82 RootRef [omitted] +83 Constant undefined +84 RootRef [omitted] +85 Object [ + {string} "url" + RootRef [omitted] + {string} "params" + Object 0 + {string} "isNavigating" + Constant false + {string} "prevUrl" +] +86 {string} "s_XpalYii770E" +87 RootRef [omitted] +88 RootRef [omitted] +89 {string} "s_69B0DK0eZJc" +90 RootRef [omitted] +91 RootRef [omitted] +92 RootRef [omitted] +93 Map [ + RootRef [omitted] + RootRef [omitted] +] +94 {string} "s_QwONcWD5gIg" +95 RootRef [omitted] +96 {string} "q-xxxxxxxx.js" +97 {string} "s_LqnNyU1Iy8c" +98 RootRef [omitted] +99 QRL "[omitted]" +100 Object [ {string} "r" RootRef [omitted] ] -102 {string} "q-xxxxxxxx.js" -103 {string} "_rsc" +101 {string} "q-xxxxxxxx.js" +102 {string} "_rsc" +103 RootRef [omitted] 104 RootRef [omitted] 105 RootRef [omitted] 106 RootRef [omitted] @@ -661,16 +684,19 @@ 117 RootRef [omitted] 118 RootRef [omitted] 119 RootRef [omitted] -120 RootRef [omitted] -121 RootRef [omitted] +120 {string} "q-xxxxxxxx.js" +121 {string} "s_9CrWYOoCpgY" 122 RootRef [omitted] 123 RootRef [omitted] -124 {string} "s_lKDAUb7ao7U" +124 RootRef [omitted] 125 RootRef [omitted] -126 Promise [ +126 RootRef [omitted] +127 {string} "s_lKDAUb7ao7U" +128 RootRef [omitted] +129 Promise [ Constant true RootRef [omitted] ] -127 ForwardRefs [ - 126 +130 ForwardRefs [ + 129 ] diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/catchall-loader/[...slug]/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/catchall-loader/[...slug]/index.tsx new file mode 100644 index 00000000000..ce92eff53ad --- /dev/null +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/catchall-loader/[...slug]/index.tsx @@ -0,0 +1,44 @@ +import { component$ } from '@qwik.dev/core'; +import { Link, routeLoader$ } from '@qwik.dev/router'; + +let runCount = 0; + +export const useCatchallLoader = routeLoader$( + ({ params, url }) => { + return { + pathname: url.pathname, + runCount: ++runCount, + slug: params.slug, + }; + }, + { serializationStrategy: 'always' } +); + +export default component$(() => { + const data = useCatchallLoader(); + + return ( +
+

Catchall Loader

+

slug: {data.value.slug}

+

pathname: {data.value.pathname}

+

runs: {data.value.runCount}

+ +
+ ); +}); diff --git a/packages/qwik-router/src/runtime/src/route-loaders.spec.tsx b/packages/qwik-router/src/runtime/src/route-loaders.spec.tsx index fb9264a8f12..ccc59222737 100644 --- a/packages/qwik-router/src/runtime/src/route-loaders.spec.tsx +++ b/packages/qwik-router/src/runtime/src/route-loaders.spec.tsx @@ -140,6 +140,37 @@ describe('route loader store + async signal tracking', () => { }); }); + it('should track route changes when the first value is injected', async () => { + await withContainer(async () => { + const ctx = createStore(container, { pageUrl: '/initial' }, 1 /* StoreFlags.RECURSIVE */); + + const computeLog: string[] = []; + const signal = createAsync$(async ({ track, info }) => { + const url = track(ctx, 'pageUrl') as string; + if (info && typeof info === 'object' && '__v' in (info as object)) { + computeLog.push('__v'); + return (info as { __v: string }).__v; + } + computeLog.push(`fetch:${url}`); + return `fetched:${url}`; + }) as AsyncSignalImpl; + + setLoaderSignalValue(signal, 'ssr-value'); + await retryOnPromise(() => { + effect$(() => signal.value); + }); + + expect(signal.value).toBe('ssr-value'); + expect(computeLog).toEqual(['__v']); + + ctx.pageUrl = '/after-nav'; + await signal.promise(); + + expect(signal.value).toBe('fetched:/after-nav'); + expect(computeLog).toEqual(['__v', 'fetch:/after-nav']); + }); + }); + it('should re-compute for TWO sequential store changes after __v', async () => { await withContainer(async () => { const ctx = createStore(container, { pageUrl: '/initial' }, 1 /* StoreFlags.RECURSIVE */); @@ -242,7 +273,7 @@ describe('route loader store + async signal tracking', () => { function effectQrl(fnQrl: QRL<() => void>) { const qrl = fnQrl as QRLInternal<() => void>; const element: HostElement = vnode_newVirtual(); - task = task || new Task(0, 0, element, fnQrl as QRLInternal, undefined, null); + task = task || new Task(0 as any, 0, element, fnQrl as QRLInternal, undefined, null); vnode_setProp(element, 'q:seq', [task]); if (!qrl.resolved) { throw qrl.resolve(); diff --git a/packages/qwik-router/src/runtime/src/route-loaders.ts b/packages/qwik-router/src/runtime/src/route-loaders.ts index 95db5f026e3..54149c6e758 100644 --- a/packages/qwik-router/src/runtime/src/route-loaders.ts +++ b/packages/qwik-router/src/runtime/src/route-loaders.ts @@ -296,13 +296,19 @@ const createRouteLoaderSignal = ( return createAsync$( async (ctx) => { const { track, info, previous, abortSignal } = ctx; + const hasInjectedValue = !!info && typeof info === 'object' && '__v' in (info as object); + let trackedRoutePath: string | undefined; + let trackedPagePathname: string | undefined; + let trackedPageSearch: string | undefined; + if (!isServer || hasInjectedValue) { + trackedRoutePath = track(routeLoaderCtx.loaderPaths, id) as string | undefined; + trackedPagePathname = track(routeLoaderCtx, 'pagePathname') as string | undefined; + trackedPageSearch = track(routeLoaderCtx, 'pageSearch') as string | undefined; + } // Pre-loaded value injection (from middleware via setLoaderSignalValue, or from - // an action response). Skipping the track() calls below is safe: route loader - // functions run on the server and have no access to client-side reactive state, - // so their compute never registers subscriptions of its own. The track() calls - // in the client branch only exist to react to route/page-URL changes, which fire - // a fresh invalidate (without info.__v) when they happen. - if (info && typeof info === 'object' && '__v' in (info as object)) { + // an action response). Client-side route dependencies must already be tracked + // here so a resumed loader can re-fetch on the first SPA navigation. + if (hasInjectedValue) { const value = (info as { __v: unknown }).__v; if (!isServer && resumeValueKey in stateValues) { stateValues[resumeValueKey] = value; @@ -313,13 +319,11 @@ const createRouteLoaderSignal = ( return (capture as ServerRouteLoaderCapture).load(); } // Track reactive dependencies so the signal re-fetches when the route path changes - const routePath = track(routeLoaderCtx.loaderPaths, id) as string | undefined; + const routePath = trackedRoutePath; // Track the client page path/search. These fields are only assigned on SPA navigation; before // that, `location` is the source of truth and avoids serializing a duplicate URL in SSR state. - const pagePathname = - (track(routeLoaderCtx, 'pagePathname') as string | undefined) || location.pathname; - const pageSearch = - (track(routeLoaderCtx, 'pageSearch') as string | undefined) || location.search; + const pagePathname = trackedPagePathname || location.pathname; + const pageSearch = trackedPageSearch || location.search; const pageUrl = new URL(pagePathname + pageSearch, location.href); const mHash = getClientManifestHash(ctx); const basePath = (qwikRouterConfig as any).basePathname ?? '/';