diff --git a/src/middleware.ts b/src/middleware.ts index f4fb9b2..66d36cc 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -255,14 +255,18 @@ function get_page_handler(routes: RouteObject, store_getter: (req: Req) => Store ? {} : get_params(page.pattern.exec(req.path)); - const chunks: Record = get_chunks(); + const chunks: Record = get_chunks(); res.setHeader('Content-Type', 'text/html'); // preload main.js and current route // TODO detect other stuff we can preload? images, CSS, fonts? - const link = [] - .concat(chunks.main, error ? [] : page.parts.map(part => chunks[part.name])) + let preloaded_chunks = Array.isArray(chunks.main) ? chunks.main : [chunks.main]; + page.parts.forEach(part => { + preloaded_chunks = preloaded_chunks.concat(chunks[part.name]); // using concat because it could be a string or an array. thanks webpack! + }); + + const link = preloaded_chunks .filter(file => !file.match(/\.map$/)) .map(file => `<${req.baseUrl}/client/${file}>;rel="preload";as="script"`) .join(', '); diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 7a43bb6..3d15b66 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -7,6 +7,31 @@ export let root: Component; let target: Node; let store: Store; let routes: Routes; +let segments: string[] = []; + +type RootProps = { + path: string; + params: Record; + query: Record; + child: Child; +}; + +type Child = { + segment?: string; + props?: any; + component?: Component; +}; + +const root_props: RootProps = { + path: null, + params: null, + query: null, + child: { + segment: null, + component: null, + props: {} + } +}; export { root as component }; // legacy reasons — drop in a future version @@ -52,10 +77,23 @@ function select_route(url: URL): Target { let current_token: {}; -function render(data: any, scroll: ScrollPosition, token: {}) { +function render(data: any, changed_from: number, scroll: ScrollPosition, token: {}) { if (current_token !== token) return; if (root) { + // first, clear out highest-level root component + let level = data.child; + for (let i = 0; i < changed_from; i += 1) { + if (i === changed_from) break; + level = level.props.child; + } + + const { component } = level; + level.component = null; + root.set({ child: data.child }); + + // then render new stuff + level.component = component; root.set(data); } else { // first load — remove SSR'd contents @@ -80,18 +118,32 @@ function render(data: any, scroll: ScrollPosition, token: {}) { window.scrollTo(scroll.x, scroll.y); } + Object.assign(root_props, data); ready = true; } +function changed(a: Record, b: Record) { + return JSON.stringify(a) !== JSON.stringify(b); +} + function prepare_page(target: Target): Promise<{ redirect?: Redirect; - data?: any + data?: any; + changed_from?: number; }> { if (root) { root.set({ preloading: true }); } const { page, path, query } = target; + const new_segments = path.split('/').filter(Boolean); + let changed_from = 0; + + while ( + segments[changed_from] && + new_segments[changed_from] && + segments[changed_from] === new_segments[changed_from] + ) changed_from += 1; let redirect: Redirect = null; let error: { statusCode: number, message: Error | string } = null; @@ -111,6 +163,8 @@ function prepare_page(target: Target): Promise<{ }; return Promise.all(page.parts.map(async (part, i) => { + if (i < changed_from) return null; + const { default: Component } = await part.component(); const req = { path, @@ -131,6 +185,8 @@ function prepare_page(target: Target): Promise<{ return { redirect }; } + segments = new_segments; + const get_params = page.parts[page.parts.length - 1].params || (() => ({})); const params = get_params(target.match); @@ -154,28 +210,37 @@ function prepare_page(target: Target): Promise<{ }; } - // TODO skip unchanged segments const props = { path, query }; - const data = { path, query, params, preloading: false, child: {} }; + const data = { + path, + preloading: false, + child: Object.assign({}, root_props.child) + }; + if (changed(query, root_props.query)) data.query = query; + if (changed(params, root_props.params)) data.params = params; + let level = data.child; for (let i = 0; i < page.parts.length; i += 1) { const part = page.parts[i]; - const get_params = page.parts[page.parts.length - 1].params || (() => ({})); + const get_params = part.params || (() => ({})); - Object.assign(level, { - // TODO segment - props: Object.assign({}, props, { + if (i < changed_from) { + level.props.path = path; + level.props.query = query; + level.props.child = Object.assign({}, level.props.child); + } else { + level.segment = new_segments[i]; + level.component = results[i].Component; + level.props = Object.assign({}, level.props, props, { params: get_params(target.match), - }, results[i].preloaded), - component: results[i].Component - }); - if (i < results.length - 1) { + }, results[i].preloaded); level.props.child = {}; } + level = level.props.child; } - return { data }; + return { data, changed_from }; }); } @@ -200,12 +265,12 @@ async function navigate(target: Target, id: number): Promise { prefetching = null; const token = current_token = {}; - const { redirect, data } = await loaded; + const { redirect, data, changed_from } = await loaded; if (redirect) { await goto(redirect.location, { replaceState: true }); } else { - render(data, scroll_history[id], token); + render(data, changed_from, scroll_history[id], token); document.activeElement.blur(); } } @@ -271,7 +336,7 @@ function handle_popstate(event: PopStateEvent) { let prefetching: { href: string; - promise: Promise<{ redirect?: Redirect, data?: any }>; + promise: Promise<{ redirect?: Redirect, data?: any, changed_from?: number }>; } = null; export function prefetch(href: string) { diff --git a/test/app/routes/[x]/[y]/[z].html b/test/app/routes/[x]/[y]/[z].html new file mode 100644 index 0000000..787e413 --- /dev/null +++ b/test/app/routes/[x]/[y]/[z].html @@ -0,0 +1,20 @@ +z: {segment} {count} + + + \ No newline at end of file diff --git a/test/app/routes/[x]/[y]/index.html b/test/app/routes/[x]/[y]/index.html new file mode 100644 index 0000000..40e92ec --- /dev/null +++ b/test/app/routes/[x]/[y]/index.html @@ -0,0 +1,20 @@ +y: {segment} {count} + + + \ No newline at end of file diff --git a/test/app/routes/[x]/_counts.js b/test/app/routes/[x]/_counts.js new file mode 100644 index 0000000..1237d12 --- /dev/null +++ b/test/app/routes/[x]/_counts.js @@ -0,0 +1,5 @@ +export default { + x: process.browser ? 1 : 0, + y: process.browser ? 1 : 0, + z: process.browser ? 1 : 0 +}; \ No newline at end of file diff --git a/test/app/routes/[x]/index.html b/test/app/routes/[x]/index.html new file mode 100644 index 0000000..0fc0f2d --- /dev/null +++ b/test/app/routes/[x]/index.html @@ -0,0 +1,20 @@ +x: {segment} {count} + + + \ No newline at end of file diff --git a/test/common/test.js b/test/common/test.js index 41efb1d..fa71158 100644 --- a/test/common/test.js +++ b/test/common/test.js @@ -632,6 +632,33 @@ function run({ mode, basepath = '' }) { assert.equal(html.indexOf('%sapper'), -1); }); }); + + it('only recreates components when necessary', () => { + return nightmare + .goto(`${base}/foo/bar/baz`) + .init() + .evaluate(() => document.querySelector('#sapper').textContent) + .then(text => { + assert.deepEqual(text.split('\n').filter(Boolean), [ + 'x: foo 1', + 'y: bar 1', + 'z: baz 1' + ]); + + return nightmare.click(`a`) + .then(() => wait(100)) + .then(() => { + return nightmare.evaluate(() => document.querySelector('#sapper').textContent); + }); + }) + .then(text => { + assert.deepEqual(text.split('\n').filter(Boolean), [ + 'x: foo 1', + 'y: bar 1', + 'z: qux 2' + ]); + }); + }); }); describe('headers', () => {