Compare commits

..
Author SHA1 Message Date
Chris Tate 8ada149395 fix(tanstack-start): preserve encoded percent params
- Preserve encoded percent signs through pathname normalization.
- Cover dynamic, splat, and static-path round trips.
2026-09-08 15:25:08 -05:00
Chris Tate 70d7383394 fix(tanstack-start): handle route recovery edge cases
- Retry failed loaders by invalidating the router.
- Normalize trailing-slash and encoded route pathnames.
- Add regression coverage and document corrected behavior.
2026-09-08 15:08:00 -05:00
Chris Tate bfab0c4a6c fix(tanstack-start): harden route transitions
- Scope page state to the rendered route during pending navigation.
- Normalize route matching across empty splats and trailing slashes.
- Preserve client boundaries in CommonJS and ESM builds.
2026-09-08 11:16:25 -05:00
Chris Tate 1d29a71d8c fix(tanstack-start): match empty splats
- Align root and trailing-slash splat matching with TanStack Router.
- Cover empty splat paths with regression tests.
2026-09-08 10:39:25 -05:00
Chris Tate e5f97d4dec fix(tanstack-start): align runtime contracts
- Merge layout state into page rendering with explicit precedence.
- Match TanStack Router string splat parameters across runtime, types, and docs.
- Preserve the client boundary and add regression coverage.
2026-09-08 10:25:28 -05:00
Chris Tate e13ffc53fc fix(tanstack-start): address review findings 2026-09-08 10:11:46 -05:00
Chris Tate 6b095856f2 feat(tanstack-start): add renderer
- Add full-app routing, SSR data helpers, metadata, layouts, and navigation.
- Validate with focused tests and the current TanStack Router type contract.
- Document the package across the API reference, renderer guide, and agent skill.
2026-09-08 09:58:13 -05:00
7 changed files with 4 additions and 142 deletions
@@ -451,8 +451,6 @@ const value = getByPath(state, '/user/name'); // "Alice"
setByPath(state, '/user/email', 'alice@example.com');
```
For prototype safety, path utilities, state stores, and SpecStream patches reject JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens. Compound patches validate both `path` and `from` before mutating data.
### resolveDynamicValue
```typescript
-2
View File
@@ -126,8 +126,6 @@ SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/ht
All six RFC 6902 operations are supported: `add`, `remove`, `replace`, `move`, `copy`, `test`.
For prototype safety, JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens are rejected by path utilities, state stores, and SpecStream. This applies to both `path` and `from` in compound patches.
### Low-Level Utilities
```typescript
+1 -35
View File
@@ -1,9 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import {
createStateStore,
flattenToPointers,
immutableSetByPath,
} from "./state-store";
import { createStateStore, flattenToPointers } from "./state-store";
describe("createStateStore", () => {
it("creates a store with initial state", () => {
@@ -139,36 +135,6 @@ describe("createStateStore", () => {
store.set("/x", 2);
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
});
it.each(["__proto__", "constructor", "prototype"])(
"rejects %s state paths without publishing a snapshot",
(token) => {
const store = createStateStore({ safe: true });
const listener = vi.fn();
const snapshot = store.getSnapshot();
store.subscribe(listener);
store.set(`/${token}/polluted`, "value");
store.update({ [`/safe/${token}/polluted`]: "value" });
expect(store.getSnapshot()).toBe(snapshot);
expect(listener).not.toHaveBeenCalled();
},
);
});
describe("immutableSetByPath", () => {
it.each(["__proto__", "constructor", "prototype"])(
"rejects %s without changing snapshot identity or its prototype",
(token) => {
const state = { safe: true };
const result = immutableSetByPath(state, `/${token}/polluted`, "value");
expect(result).toBe(state);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(result).toEqual({ safe: true });
},
);
});
describe("flattenToPointers", () => {
-7
View File
@@ -1,6 +1,5 @@
import {
getByPath,
isSafeJsonPointerPath,
parseJsonPointer,
type StateModel,
type StateStore,
@@ -16,8 +15,6 @@ export function immutableSetByPath(
path: string,
value: unknown,
): StateModel {
if (!isSafeJsonPointerPath(path)) return root;
const segments = parseJsonPointer(path);
if (segments.length === 0) return root;
@@ -75,7 +72,6 @@ export function createStateStore(initialState: StateModel = {}): StateStore {
},
set(path: string, value: unknown): void {
if (!isSafeJsonPointerPath(path)) return;
if (getByPath(state, path) === value) return;
state = immutableSetByPath(state, path, value);
notify();
@@ -85,7 +81,6 @@ export function createStateStore(initialState: StateModel = {}): StateStore {
let changed = false;
let next = state;
for (const [path, value] of Object.entries(updates)) {
if (!isSafeJsonPointerPath(path)) continue;
if (getByPath(next, path) !== value) {
next = immutableSetByPath(next, path, value);
changed = true;
@@ -142,7 +137,6 @@ export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
},
set(path: string, value: unknown): void {
if (!isSafeJsonPointerPath(path)) return;
const current = config.getSnapshot();
if (getByPath(current, path) === value) return;
config.setSnapshot(immutableSetByPath(current, path, value));
@@ -152,7 +146,6 @@ export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
let next = config.getSnapshot();
let changed = false;
for (const [path, value] of Object.entries(updates)) {
if (!isSafeJsonPointerPath(path)) continue;
if (getByPath(next, path) !== value) {
next = immutableSetByPath(next, path, value);
changed = true;
-61
View File
@@ -215,67 +215,6 @@ describe("JSON Pointer escaping (RFC 6901)", () => {
});
});
// =============================================================================
// JSON Pointer prototype safety
// =============================================================================
describe("JSON Pointer prototype safety", () => {
const blockedTokens = ["__proto__", "constructor", "prototype"];
it.each(blockedTokens)("rejects %s in path utility writes", (token) => {
const pollutionKey = "__json_render_pollution_probe__";
const data: Record<string, unknown> = {};
try {
setByPath(data, `/${token}/${pollutionKey}`, "set");
addByPath(data, `/safe/${token}/${pollutionKey}`, "add");
expect(data).toEqual({});
expect(Object.prototype).not.toHaveProperty(pollutionKey);
} finally {
delete (Object.prototype as Record<string, unknown>)[pollutionKey];
}
});
it("does not read or remove values through Object.prototype", () => {
const pollutionKey = "__json_render_inherited_probe__";
(Object.prototype as Record<string, unknown>)[pollutionKey] = "keep";
try {
expect(getByPath({}, `/__proto__/${pollutionKey}`)).toBeUndefined();
removeByPath({}, `/__proto__/${pollutionKey}`);
expect((Object.prototype as Record<string, unknown>)[pollutionKey]).toBe(
"keep",
);
} finally {
delete (Object.prototype as Record<string, unknown>)[pollutionKey];
}
});
it.each(blockedTokens)(
"rejects compound patches containing %s before mutation",
(token) => {
const destination: Record<string, unknown> = { source: "one" };
applySpecStreamPatch(destination, {
op: "move",
from: "/source",
path: `/${token}/moved`,
});
const source: Record<string, unknown> = {};
applySpecStreamPatch(source, {
op: "copy",
from: `/${token}/value`,
path: "/copy",
});
expect(destination).toEqual({ source: "one" });
expect(source).toEqual({});
expect(Object.hasOwn(source, "copy")).toBe(false);
},
);
});
// =============================================================================
// addByPath (RFC 6902 "add" semantics)
// =============================================================================
+3 -33
View File
@@ -280,26 +280,6 @@ export function parseJsonPointer(path: string): string[] {
return raw.map(unescapeJsonPointer);
}
const blockedJsonPointerTokens = new Set([
"__proto__",
"constructor",
"prototype",
]);
/**
* Reject tokens that can traverse or modify JavaScript prototype chains.
* Validation happens after JSON Pointer unescaping so encoded paths cannot
* bypass it.
*/
function hasBlockedJsonPointerToken(segments: string[]): boolean {
return segments.some((segment) => blockedJsonPointerTokens.has(segment));
}
/** @internal Shared by JSON Pointer-based state stores. */
export function isSafeJsonPointerPath(path: string): boolean {
return !hasBlockedJsonPointerToken(parseJsonPointer(path));
}
/**
* Get a value from an object by JSON Pointer path (RFC 6901)
*/
@@ -309,7 +289,6 @@ export function getByPath(obj: unknown, path: string): unknown {
}
const segments = parseJsonPointer(path);
if (hasBlockedJsonPointerToken(segments)) return undefined;
let current: unknown = obj;
@@ -383,7 +362,7 @@ export function setByPath(
): void {
const segments = parseJsonPointer(path);
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
if (segments.length === 0) return;
let current: Record<string, unknown> | unknown[] = obj;
@@ -433,7 +412,7 @@ export function addByPath(
): void {
const segments = parseJsonPointer(path);
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
if (segments.length === 0) return;
let current: Record<string, unknown> | unknown[] = obj;
@@ -479,7 +458,7 @@ export function addByPath(
export function removeByPath(obj: Record<string, unknown>, path: string): void {
const segments = parseJsonPointer(path);
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
if (segments.length === 0) return;
let current: Record<string, unknown> | unknown[] = obj;
@@ -638,15 +617,6 @@ export function applySpecStreamPatch<T extends Record<string, unknown>>(
obj: T,
patch: SpecStreamLine,
): T {
if (!isSafeJsonPointerPath(patch.path)) return obj;
if (
(patch.op === "move" || patch.op === "copy") &&
patch.from !== undefined &&
!isSafeJsonPointerPath(patch.from)
) {
return obj;
}
switch (patch.op) {
case "add":
addByPath(obj, patch.path, patch.value);
-2
View File
@@ -77,8 +77,6 @@ const { result, newPatches } = compiler.push(chunk);
const finalSpec = compiler.getResult();
```
JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens are rejected by path utilities, state stores, and SpecStream. For `move` and `copy`, this applies to both `path` and `from`.
## Dynamic Prop Expressions
Any prop value can be a dynamic expression resolved at render time: