feat(apps): restore where a builtin app page left off - #8407
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of All evidence reviewed: the diff adds no new UI chrome or strings — it restores the drive's folder position across navigation (per account), the screenshots are sanity captures of existing surfaces, and breadcrumbs already orient a user restored into a deep folder. A restored-but-since-deleted prefix lands on the drive's existing empty-folder state with the breadcrumb as the way out, so no unrecoverable or confusing state is introduced. All failure paths (corrupt/old record) silently mount defaults, which is the correct user-facing behavior for a convenience restore. UX-Verdict: PASS Removes real memory excise — the drive now reopens in the folder you left, per account, with breadcrumbs already there to show where you landed. [UX-REVIEWED] 7dba54d |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound host-owned store with structural scoping, but ~1MB of Watch
[DESIGN-REVIEWED] 7dba54d |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All counts verified. Writing the review. First-Principles-Verdict: CONCERNS A 479-line generalized store ships for one string field, while What this change shipsIntent: coming back to a builtin app page should land where you left it — the drive's folder, per account. ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 7dba54d |
efa6f2b to
3969810
Compare
3969810 to
d48c6bd
Compare
|
Fixed in I reproduced the sequence against the real functions before changing anything. With the per-app key, the drive reads back the library's record, finds So it was not merely a clobber -- it was a clobber that reported success, which is the one shape neither consumer's own suite could ever catch.
A key segment rather than several sections in one record, for a reason worth stating: a shared record has to be read-modify-written by every consumer, which is the shape that lets one writer clobber a field it holds a stale copy of. Separate keys give each surface an independent lifetime, so a corrupt record on one pane cannot take another pane's position with it.
Three tests pin it, and both new guards are mutation-checked (delete the segment from the key, or the charset check, and a named test fails):
I did not take the alternative you offered (an asserted one-decl-per-app rule), because it constrains apps for the store's convenience and would fire at runtime in production rather than at author time. 257 tests green across 9 files; 13 mutation guards each failing a named test. |
9f9b0c6 to
5442b13
Compare
57bb8a1 to
dafc5ac
Compare
|
Three items. One is a real defect and is fixed; one is a fair reduction and is taken; the two subtractions I am declining, and one of them rests on a premise I can show is false. Fixed: the spec contradictionCorrect and load-bearing. The correction keeps a distinction the old text collapsed: URL-backed view position is still NOT an App SDK contract, because this PR adds no URL synchronisation, so that section is retitled rather than deleted and now states why the two are complementary. One sentence I deliberately kept as-is: the barrel really does export no Taken: the test-only export
Declining: drop
|
Bundle size, measured against this PR's own baseRecording this pre-emptively because a Built both sides with The chunk that trips the gate is untouched by this diff. The gate passes on this branch. exit 0. What this diff does cost, for completeness:
One side effect worth disclosing. A shared chunk is renamed: base emits Both builds are reproducible: rebuilding this head a second time produced a byte-identical manifest. |
dafc5ac to
cdde9c5
Compare
|
Taken, in I went looking for a precedent to defend the exports and did not find one:
The record-level properties moved to the hook, which is the surface a caller has: seed storage, mount, read back what was restored and what was written. Re-running the mutation matrix is what made this worth doingMoving those tests made three guards stop being observable, which I would not have known from a green suite:
One guard I am now reporting as NOT observable instead of counting it: the loop that serializes declared fields in stable order. With one declared field, ordered and plain serialization are byte-identical, so deleting it breaks nothing and no honest test exists. I kept it because it is the last structural point at which an undeclared field cannot be serialized, and because byte-stability is what the defaults comparison relies on -- both begin to matter at the second declared field. The count is twelve observable guards, not thirteen.
|
c0f4d5d to
95de3ae
Compare
95de3ae to
abb85ac
Compare
|
The three remaining subtractions all reduce to decisions the maintainer has already made explicitly, so I am dispositioning them rather than either taking them unilaterally or arguing further. The reporting tier is not independent. You would keep the corrupt-record
Remove-on-default is a real trade, and I would resolve it the other way -- though it is also Where I think the reasoning goes wrong is the equivalence with Two things I will concede about it. It is hygiene, not correctness: without removal a stale or corrupt record is overwritten with a defaults record instead of deleted, which discards the bad data just as effectively, and the user lands at the root either way. And inheriting 265 tests green across 10 files; 12 observable mutation guards each failing a named test, from one committed baseline, with the thirteenth reported as unobservable rather than counted. |
A builtin page is unmounted on navigation, so its component state returns to defaults on every visit. AWS Control's drive is the reported case: descend into a folder, leave, come back, and you are at the bucket root again. Add a host-owned view-state store. An app declares the few coordinates worth restoring; the host owns the key (kc:app:<appId>:view, appId from the identity context) and decides what happens when a record cannot be read back. The declaration is a filter, not documentation: pickDeclared runs on every write, so a field the app did not declare cannot reach storage. Passing a whole component state in persists only the coordinates, which is what makes 'do not persist the drive's contents' an enforced property. The record is read in a useState initializer, so the restored value exists on the consumer's first render and the drive's infinite query is keyed to the right folder from its first request -- no wasted root listing, no skeleton flash. Reading in an effect would look correct on a cold visit, because a React.lazy child is still suspended while its parent commits, and be a render too late on the repeat visit this feature exists to serve. scope is first-class: a prefix means nothing outside the bucket it was taken in, so a mismatched scope resolves to the defaults through the same path as a mismatched revision, and a scope change re-reads during render rather than carrying a position forward under a new label. Tests: the write filter, every parse rejection, the builtin gate, first-render publication, the scope re-read, the three reporting tiers, and the drive's first request.
abb85ac to
7dba54d
Compare
f83052d
into
feat/builtin-app-identity-seam
Two halves of one symptom, both standing on the identity seam in the previous commit. Leaving a builtin app page unmounts it, so the page returns to its defaults AND the data it was showing is collected shortly after. Coming back lands at the top level, behind loading placeholders. AWS Control's drive is the reported case: descend into a folder, leave, come back, and you are at the bucket root again with a skeleton. VIEW STATE (#8407) Add a host-owned view-state store. An app declares the few coordinates worth restoring; the host owns the key (kc:app:<appId>:view, appId from the identity context) and decides what happens when a record cannot be read back. The declaration is a filter, not documentation: pickDeclared runs on every write, so a field the app did not declare cannot reach storage. Passing a whole component state in persists only the coordinates, which is what makes 'do not persist the drive's contents' an enforced property. The record is read in a useState initializer, so the restored value exists on the consumer's first render and the drive's infinite query is keyed to the right folder from its first request -- no wasted root listing, no skeleton flash. Reading in an effect would look correct on a cold visit, because a React.lazy child is still suspended while its parent commits, and be a render too late on the repeat visit this feature exists to serve. scope is first-class: a prefix means nothing outside the bucket it was taken in, so a mismatched scope resolves to the defaults through the same path as a mismatched revision, and a scope change re-reads during render rather than carrying a position forward under a new label. CACHE RETENTION (#8404) react-query's 5-minute default gcTime collects an unmounted page's data. One app of nineteen was exempt, and only because it fixed this in app-local code no other app could reuse: issue-radar called setQueryDefaults(['issue-radar'], { gcTime }) at module scope. Give the host the same one-liner, per app. BuiltinAppRoute already knows which app owns the route before the page's first query mounts, so a sibling ahead of its Suspense boundary reads the appId from the identity context and registers retention for the [appId] key prefix. react-query matches query defaults by prefix, so that one registration covers every key the app already writes by hand -- AWS Control's accounts, drive and costs queries are all under ['aws-control', ...], so the reported symptom is fixed with no change to the app. Registered from a render body, not an effect, for the same reason the view-state read is: an effect version passes a cold-load test and still fails a user, since on a repeat visit React renders parent and child in one pass. useAppQuery is the other half: it takes the appId from context and prefixes the key, so an app cannot forge its own namespace, and code that wants the HOST's cache stays on plain useQuery where the difference is greppable. The prefix is exactly the appId and not ['app', appId, ...], which is the only shape that also leaves every existing key untouched: five prefixes are shared between an app and the host deliberately -- artifact, awsConsent, apps, pull-request-source, and workflow-definitions, whose rename would split the workflow cards in chat off the list in the app. What changes is who authors the prefix, not what it is. Two call sites converted, deliberately mixed in opposite directions: UsagePane's costs query is host-built while its invalidation stays a hand-written literal, and its drive invalidation is host-built while the query stays a literal. Either mismatch would stop a consent grant refreshing what it changed, so the pane exercises the byte-identity claim instead of asserting it. The remaining 33 sites follow separately (#8401). issue-radar's own registration is deleted, because the host now covers that exact prefix and keeping both was not merely redundant: setQueryDefaults is a Map keyed by the hashed key, so both wrote the same entry and the last writer won -- decided by whether the app's lazy chunk had evaluated yet. Identical values hid it; a future change to either number would not have. Tests: the write filter, every parse rejection, the builtin gate, first-render publication, the scope re-read, the three reporting tiers, and the drive's first request; then retention refusals for a host page and an external origin, keys byte-identical to the literals they replace in both directions, degrade to a plain query with no namespace, already-prefixed keys collapsing with one warning, one registration per client and app, cold AND warm ordering each self-contained, the namespace surviving both api-layer providers nested inside a builtin page -- the shape spec-builder and IncidentChat use, where a shadowed identity would put an app's data outside the very namespace being retained with no error to see -- and an A/B on a faked clock showing the data present at six minutes and collected at thirty with an identity, gone at six without one, which is the reported symptom reproduced. Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
Two halves of one symptom, both standing on the identity seam in the previous commit. Leaving a builtin app page unmounts it, so the page returns to its defaults AND the data it was showing is collected shortly after. Coming back lands at the top level, behind loading placeholders. AWS Control's drive is the reported case: descend into a folder, leave, come back, and you are at the bucket root again with a skeleton. VIEW STATE (#8407) Add a host-owned view-state store. An app declares the few coordinates worth restoring; the host owns the key (kc:app:<appId>:view, appId from the identity context) and decides what happens when a record cannot be read back. The declaration is a filter, not documentation: pickDeclared runs on every write, so a field the app did not declare cannot reach storage. Passing a whole component state in persists only the coordinates, which is what makes 'do not persist the drive's contents' an enforced property. The record is read in a useState initializer, so the restored value exists on the consumer's first render and the drive's infinite query is keyed to the right folder from its first request -- no wasted root listing, no skeleton flash. Reading in an effect would look correct on a cold visit, because a React.lazy child is still suspended while its parent commits, and be a render too late on the repeat visit this feature exists to serve. scope is first-class: a prefix means nothing outside the bucket it was taken in, so a mismatched scope resolves to the defaults through the same path as a mismatched revision, and a scope change re-reads during render rather than carrying a position forward under a new label. CACHE RETENTION (#8404) react-query's 5-minute default gcTime collects an unmounted page's data. One app of nineteen was exempt, and only because it fixed this in app-local code no other app could reuse: issue-radar called setQueryDefaults(['issue-radar'], { gcTime }) at module scope. Give the host the same one-liner, per app. BuiltinAppRoute already knows which app owns the route before the page's first query mounts, so a sibling ahead of its Suspense boundary reads the appId from the identity context and registers retention for the [appId] key prefix. react-query matches query defaults by prefix, so that one registration covers every key the app already writes by hand -- AWS Control's accounts, drive and costs queries are all under ['aws-control', ...], so the reported symptom is fixed with no change to the app. Registered from a render body, not an effect, for the same reason the view-state read is: an effect version passes a cold-load test and still fails a user, since on a repeat visit React renders parent and child in one pass. useAppQuery is the other half: it takes the appId from context and prefixes the key, so an app cannot forge its own namespace, and code that wants the HOST's cache stays on plain useQuery where the difference is greppable. The prefix is exactly the appId and not ['app', appId, ...], which is the only shape that also leaves every existing key untouched: five prefixes are shared between an app and the host deliberately -- artifact, awsConsent, apps, pull-request-source, and workflow-definitions, whose rename would split the workflow cards in chat off the list in the app. What changes is who authors the prefix, not what it is. Two call sites converted, deliberately mixed in opposite directions: UsagePane's costs query is host-built while its invalidation stays a hand-written literal, and its drive invalidation is host-built while the query stays a literal. Either mismatch would stop a consent grant refreshing what it changed, so the pane exercises the byte-identity claim instead of asserting it. The remaining 33 sites follow separately (#8401). issue-radar's own registration is deleted, because the host now covers that exact prefix and keeping both was not merely redundant: setQueryDefaults is a Map keyed by the hashed key, so both wrote the same entry and the last writer won -- decided by whether the app's lazy chunk had evaluated yet. Identical values hid it; a future change to either number would not have. Tests: the write filter, every parse rejection, the builtin gate, first-render publication, the scope re-read, the three reporting tiers, and the drive's first request; then retention refusals for a host page and an external origin, keys byte-identical to the literals they replace in both directions, degrade to a plain query with no namespace, already-prefixed keys collapsing with one warning, one registration per client and app, cold AND warm ordering each self-contained, the namespace surviving both api-layer providers nested inside a builtin page -- the shape spec-builder and IncidentChat use, where a shadowed identity would put an app's data outside the very namespace being retained with no error to see -- and an A/B on a faked clock showing the data present at six minutes and collected at thirty with an identity, gone at six without one, which is the reported symptom reproduced. Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
Two halves of one symptom, both standing on the identity seam in the previous commit. Leaving a builtin app page unmounts it, so the page returns to its defaults AND the data it was showing is collected shortly after. Coming back lands at the top level, behind loading placeholders. AWS Control's drive is the reported case: descend into a folder, leave, come back, and you are at the bucket root again with a skeleton. VIEW STATE (#8407) Add a host-owned view-state store. An app declares the few coordinates worth restoring; the host owns the key (kc:app:<appId>:view, appId from the identity context) and decides what happens when a record cannot be read back. The declaration is a filter, not documentation: pickDeclared runs on every write, so a field the app did not declare cannot reach storage. Passing a whole component state in persists only the coordinates, which is what makes 'do not persist the drive's contents' an enforced property. The record is read in a useState initializer, so the restored value exists on the consumer's first render and the drive's infinite query is keyed to the right folder from its first request -- no wasted root listing, no skeleton flash. Reading in an effect would look correct on a cold visit, because a React.lazy child is still suspended while its parent commits, and be a render too late on the repeat visit this feature exists to serve. scope is first-class: a prefix means nothing outside the bucket it was taken in, so a mismatched scope resolves to the defaults through the same path as a mismatched revision, and a scope change re-reads during render rather than carrying a position forward under a new label. CACHE RETENTION (#8404) react-query's 5-minute default gcTime collects an unmounted page's data. One app of nineteen was exempt, and only because it fixed this in app-local code no other app could reuse: issue-radar called setQueryDefaults(['issue-radar'], { gcTime }) at module scope. Give the host the same one-liner, per app. BuiltinAppRoute already knows which app owns the route before the page's first query mounts, so a sibling ahead of its Suspense boundary reads the appId from the identity context and registers retention for the [appId] key prefix. react-query matches query defaults by prefix, so that one registration covers every key the app already writes by hand -- AWS Control's accounts, drive and costs queries are all under ['aws-control', ...], so the reported symptom is fixed with no change to the app. Registered from a render body, not an effect, for the same reason the view-state read is: an effect version passes a cold-load test and still fails a user, since on a repeat visit React renders parent and child in one pass. useAppQuery is the other half: it takes the appId from context and prefixes the key, so an app cannot forge its own namespace, and code that wants the HOST's cache stays on plain useQuery where the difference is greppable. The prefix is exactly the appId and not ['app', appId, ...], which is the only shape that also leaves every existing key untouched: five prefixes are shared between an app and the host deliberately -- artifact, awsConsent, apps, pull-request-source, and workflow-definitions, whose rename would split the workflow cards in chat off the list in the app. What changes is who authors the prefix, not what it is. Two call sites converted, deliberately mixed in opposite directions: UsagePane's costs query is host-built while its invalidation stays a hand-written literal, and its drive invalidation is host-built while the query stays a literal. Either mismatch would stop a consent grant refreshing what it changed, so the pane exercises the byte-identity claim instead of asserting it. The remaining 33 sites follow separately (#8401). issue-radar's own registration is deleted, because the host now covers that exact prefix and keeping both was not merely redundant: setQueryDefaults is a Map keyed by the hashed key, so both wrote the same entry and the last writer won -- decided by whether the app's lazy chunk had evaluated yet. Identical values hid it; a future change to either number would not have. Tests: the write filter, every parse rejection, the builtin gate, first-render publication, the scope re-read, the three reporting tiers, and the drive's first request; then retention refusals for a host page and an external origin, keys byte-identical to the literals they replace in both directions, degrade to a plain query with no namespace, already-prefixed keys collapsing with one warning, one registration per client and app, cold AND warm ordering each self-contained, the namespace surviving both api-layer providers nested inside a builtin page -- the shape spec-builder and IncidentChat use, where a shadowed identity would put an app's data outside the very namespace being retained with no error to see -- and an A/B on a faked clock showing the data present at six minutes and collected at thirty with an identity, gone at six without one, which is the reported symptom reproduced. Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
Two halves of one symptom, both standing on the identity seam in the previous commit. Leaving a builtin app page unmounts it, so the page returns to its defaults AND the data it was showing is collected shortly after. Coming back lands at the top level, behind loading placeholders. AWS Control's drive is the reported case: descend into a folder, leave, come back, and you are at the bucket root again with a skeleton. VIEW STATE (#8407) Add a host-owned view-state store. An app declares the few coordinates worth restoring; the host owns the key (kc:app:<appId>:view, appId from the identity context) and decides what happens when a record cannot be read back. The declaration is a filter, not documentation: pickDeclared runs on every write, so a field the app did not declare cannot reach storage. Passing a whole component state in persists only the coordinates, which is what makes 'do not persist the drive's contents' an enforced property. The record is read in a useState initializer, so the restored value exists on the consumer's first render and the drive's infinite query is keyed to the right folder from its first request -- no wasted root listing, no skeleton flash. Reading in an effect would look correct on a cold visit, because a React.lazy child is still suspended while its parent commits, and be a render too late on the repeat visit this feature exists to serve. scope is first-class: a prefix means nothing outside the bucket it was taken in, so a mismatched scope resolves to the defaults through the same path as a mismatched revision, and a scope change re-reads during render rather than carrying a position forward under a new label. CACHE RETENTION (#8404) react-query's 5-minute default gcTime collects an unmounted page's data. One app of nineteen was exempt, and only because it fixed this in app-local code no other app could reuse: issue-radar called setQueryDefaults(['issue-radar'], { gcTime }) at module scope. Give the host the same one-liner, per app. BuiltinAppRoute already knows which app owns the route before the page's first query mounts, so a sibling ahead of its Suspense boundary reads the appId from the identity context and registers retention for the [appId] key prefix. react-query matches query defaults by prefix, so that one registration covers every key the app already writes by hand -- AWS Control's accounts, drive and costs queries are all under ['aws-control', ...], so the reported symptom is fixed with no change to the app. Registered from a render body, not an effect, for the same reason the view-state read is: an effect version passes a cold-load test and still fails a user, since on a repeat visit React renders parent and child in one pass. useAppQuery is the other half: it takes the appId from context and prefixes the key, so an app cannot forge its own namespace, and code that wants the HOST's cache stays on plain useQuery where the difference is greppable. The prefix is exactly the appId and not ['app', appId, ...], which is the only shape that also leaves every existing key untouched: five prefixes are shared between an app and the host deliberately -- artifact, awsConsent, apps, pull-request-source, and workflow-definitions, whose rename would split the workflow cards in chat off the list in the app. What changes is who authors the prefix, not what it is. Two call sites converted, deliberately mixed in opposite directions: UsagePane's costs query is host-built while its invalidation stays a hand-written literal, and its drive invalidation is host-built while the query stays a literal. Either mismatch would stop a consent grant refreshing what it changed, so the pane exercises the byte-identity claim instead of asserting it. The remaining 33 sites follow separately (#8401). issue-radar's own registration is deleted, because the host now covers that exact prefix and keeping both was not merely redundant: setQueryDefaults is a Map keyed by the hashed key, so both wrote the same entry and the last writer won -- decided by whether the app's lazy chunk had evaluated yet. Identical values hid it; a future change to either number would not have. Tests: the write filter, every parse rejection, the builtin gate, first-render publication, the scope re-read, the three reporting tiers, and the drive's first request; then retention refusals for a host page and an external origin, keys byte-identical to the literals they replace in both directions, degrade to a plain query with no namespace, already-prefixed keys collapsing with one warning, one registration per client and app, cold AND warm ordering each self-contained, the namespace surviving both api-layer providers nested inside a builtin page -- the shape spec-builder and IncidentChat use, where a shadowed identity would put an app's data outside the very namespace being retained with no error to see -- and an A/B on a faked clock showing the data present at six minutes and collected at thirty with an identity, gone at six without one, which is the reported symptom reproduced. Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
Two halves of one symptom, both standing on the identity seam in the previous commit. Leaving a builtin app page unmounts it, so the page returns to its defaults AND the data it was showing is collected shortly after. Coming back lands at the top level, behind loading placeholders. AWS Control's drive is the reported case: descend into a folder, leave, come back, and you are at the bucket root again with a skeleton. VIEW STATE (#8407) Add a host-owned view-state store. An app declares the few coordinates worth restoring; the host owns the key (kc:app:<appId>:view, appId from the identity context) and decides what happens when a record cannot be read back. The declaration is a filter, not documentation: pickDeclared runs on every write, so a field the app did not declare cannot reach storage. Passing a whole component state in persists only the coordinates, which is what makes 'do not persist the drive's contents' an enforced property. The record is read in a useState initializer, so the restored value exists on the consumer's first render and the drive's infinite query is keyed to the right folder from its first request -- no wasted root listing, no skeleton flash. Reading in an effect would look correct on a cold visit, because a React.lazy child is still suspended while its parent commits, and be a render too late on the repeat visit this feature exists to serve. scope is first-class: a prefix means nothing outside the bucket it was taken in, so a mismatched scope resolves to the defaults through the same path as a mismatched revision, and a scope change re-reads during render rather than carrying a position forward under a new label. CACHE RETENTION (#8404) react-query's 5-minute default gcTime collects an unmounted page's data. One app of nineteen was exempt, and only because it fixed this in app-local code no other app could reuse: issue-radar called setQueryDefaults(['issue-radar'], { gcTime }) at module scope. Give the host the same one-liner, per app. BuiltinAppRoute already knows which app owns the route before the page's first query mounts, so a sibling ahead of its Suspense boundary reads the appId from the identity context and registers retention for the [appId] key prefix. react-query matches query defaults by prefix, so that one registration covers every key the app already writes by hand -- AWS Control's accounts, drive and costs queries are all under ['aws-control', ...], so the reported symptom is fixed with no change to the app. Registered from a render body, not an effect, for the same reason the view-state read is: an effect version passes a cold-load test and still fails a user, since on a repeat visit React renders parent and child in one pass. useAppQuery is the other half: it takes the appId from context and prefixes the key, so an app cannot forge its own namespace, and code that wants the HOST's cache stays on plain useQuery where the difference is greppable. The prefix is exactly the appId and not ['app', appId, ...], which is the only shape that also leaves every existing key untouched: five prefixes are shared between an app and the host deliberately -- artifact, awsConsent, apps, pull-request-source, and workflow-definitions, whose rename would split the workflow cards in chat off the list in the app. What changes is who authors the prefix, not what it is. Two call sites converted, deliberately mixed in opposite directions: UsagePane's costs query is host-built while its invalidation stays a hand-written literal, and its drive invalidation is host-built while the query stays a literal. Either mismatch would stop a consent grant refreshing what it changed, so the pane exercises the byte-identity claim instead of asserting it. The remaining 33 sites follow separately (#8401). issue-radar's own registration is deleted, because the host now covers that exact prefix and keeping both was not merely redundant: setQueryDefaults is a Map keyed by the hashed key, so both wrote the same entry and the last writer won -- decided by whether the app's lazy chunk had evaluated yet. Identical values hid it; a future change to either number would not have. Tests: the write filter, every parse rejection, the builtin gate, first-render publication, the scope re-read, the three reporting tiers, and the drive's first request; then retention refusals for a host page and an external origin, keys byte-identical to the literals they replace in both directions, degrade to a plain query with no namespace, already-prefixed keys collapsing with one warning, one registration per client and app, cold AND warm ordering each self-contained, the namespace surviving both api-layer providers nested inside a builtin page -- the shape spec-builder and IncidentChat use, where a shadowed identity would put an app's data outside the very namespace being retained with no error to see -- and an A/B on a faked clock showing the data present at six minutes and collected at thirty with an identity, gone at six without one, which is the reported symptom reproduced. Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
Part of the app view-state and cache-retention work. Three PRs, one prerequisite:
#8407 and #8404 are siblings, not a chain: either may merge first once #8403 is in.
Related: #8412 (stale bundle ceiling on main, merged), #8394 (external app cache
isolation, separate track), #8401 (convert the remaining AWS Control key sites,
after #8407).
This PR is the view-state store section of that design.
1. What is the problem?
A builtin app page is unmounted on navigation.
BuiltinAppRouterenders one lazy component under a catch-all route, React Router tears it down when you leave, and component-local state returns to its defaults on the next visit.AWS Control's drive is the reported case. The current folder lives in
useState('')atDrivePage.tsx, and nothing writes it anywhere durable, so descending into a folder, leaving, and coming back puts you at the bucket root again.The platform had nowhere to put that state. There was no shared store, no namespace an app could be given, and nothing that decided what happens when a saved record can no longer be read.
2. Why this issue matters to the user
An app that forgets where you were on every visit cannot be a place you come back to. It pushes you toward one long-lived session and penalises navigating at all, which is the opposite of what a dashboard made of many small apps is for.
The cost is not only attention. The drive listing is refetched from the root on every return, so the user waits behind a skeleton for data they were already looking at a moment ago.
Worth being honest about the size of the delta here: for this app it is ONE field. The active pane is already in the URL via
usePaneFromPath, the grid/list choice already persists throughuseViewMode, and the selected account already persists throughusePersistedString. The state the design's ownership table says to exclude largely does not exist in this app at all -- there is no scroll-position state, no sort order, no expanded-tree model. What was missing is the folder, plus the scope that makes a folder meaningful.Screenshot evidence
Captured from an isolated pod on port 7944 (zero AWS accounts, zero prior sessions) running the served bundle whose
AwsControlPagechunk containskc:app:x1,:view:x1, and the old per-app key shape x0.Dashboard with AWS Control enabled in the sidebar -- the app is reachable and renders the Accounts & credentials page (no AWS accounts connected on the pod, so the Drive pane is gated behind
drive?.existsand cannot show the folder restore).Discover page listing AWS Control with the Enable button -- the app card on the app store, before enabling.
Clean dashboard on a fresh pod -- no prior sessions, no PII.
The folder restore behaviour itself (descend into a subfolder, navigate away, return, land in the subfolder) cannot be demonstrated on this pod because
DrivePaneGaterequires a connected AWS account with an existing S3 bucket. The behaviour is covered by the 265 passing tests including the mutation matrix in section 4; a live demo requires a pod with AWS credentials.(If a reviewer's own raw-URL viewer does not render the images, open the Files changed tab -- the PNGs are in
temp-screenshots/app-view-state-store/.)3. How our fix solves it
A host-owned view-state store:
website/src/app-sdk/viewState.ts. An app declares the few coordinates worth restoring; the host owns the key and the failure policy.Chaining from the symptom to the root cause:
pathwas component-local and the component is unmounted on navigation.pathnow comes from the store instead of local state. The five existingsetPath(...)call sites are unchanged, and there is one source of truth for the folder rather than two.path. So the record is read in auseStateinitializer: the restored value exists on the consumer's FIRST render, and the firstdriveListrequest is for the restored folder. No wasted listing of the root, no skeleton on the way to somewhere the user was not going. Reading in an effect would look correct on a cold visit, because aReact.lazychild is still suspended while its parent commits, and would be a render too late on the repeat visit this feature exists to serve.kc:app:<appId>:view:<name>:<scope>. A record cannot be read under the wrong scope, which makes the isolation structural rather than a comparison someone can forget -- and each account keeps its own position, so a user working across two accounts finds both folders where they left them.kc:app:<appId>:view:<surface>[:<scope>], with appId read from identity context throughuseTrustedAppId()-- a namespace is granted only for a builtin origin, and refused for a host page or an external app. An app cannot name its own namespace, because the id is not a parameter it passes. The identity that hook reads is what this PR is stacked on.Two properties are mechanisms here rather than rules someone is trusted to follow:
pickDeclaredruns on every write, so a field the app did not declare cannot reach storage. Handing the store a whole component state persists only the coordinates. This is what makes "do not persist the drive's contents" an enforced property instead of a documented intention, and a test passes contents in and asserts they are dropped.useTrustedAppId()is the single builtin gate, read through that hook rather than by testingoriginat each consumer. Itsnullcovers both a host page and an external app.Grounding for the scope choice
accountis always a resolved, non-empty id by the timeDriveSectionViewfirst renders:AwsControlPagereturns the accounts pane while!selected, andDrivePaneGateyields children only oncedrive?.exists. This is load-bearing rather than incidental -- ifaccountwere briefly the empty string, the scope would never match and the restore would silently never happen. Stating it because a reviewer cannot otherwise tell that it was checked.One record per SURFACE, not per app
Raised by Design Review, and it was right. The key first read
kc:app:<appId>:view-- one record per app -- while the hook is consumed per component, each with its own declaration. So a second consumer in the same app (the library or backup pane) would write only ITS declared fields and erase the drive's folder.The erasure is not the bad part. Reproduced against the real functions before fixing: the drive reads back that record, finds
pathmerely ABSENT, falls back to the default, and reports outcomerestored. A silent loss dressed as a successful restore, invisible to either consumer's own test suite.So
ViewStateDeclnow carries a requirednameand the key iskc:app:<appId>:view:<name>. Fixed in this PR rather than when a second consumer appears, because an in-blobrevisioncannot express a change to the KEY's shape -- by then the old key holds real user records that nothing can migrate. Right now the format has zero installed records, so it costs nothing.A key segment rather than several sections inside one record, deliberately. A shared record has to be read-modify-written by every consumer, which is the shape that lets one writer clobber a field it holds a stale copy of. Separate keys give each surface an independent lifetime, so a corrupt record on one pane cannot take another pane's position with it.
nameis validated as a key segment on the same charset as appId, and throws rather than sanitizing, followingAppScopedApiProvider's precedent in this layer: it is authored in code as part of a module-level declaration and is never data, so a bad one is a developer error that should fail identically everywhere.Why the existing awsControl.* keys were not moved
The selected account (
awsControl.selectedAccount) and the view modes (awsControl.drive.viewMode.*) stay exactly where they are.Relocating working keys costs either migration-read code or silently dropping every current user's stored account, for no user-visible gain. The account is already durable, and its consumer already resolves an id that no longer exists by falling back to the first resolved account -- that is account business logic, not view position. What this PR adds instead is the guarantee neither store had: the account travels in the record as its SCOPE, so a folder can never be restored into a bucket it was not taken in.
View mode stays out for a different reason: it is a display preference with a deliberately different lifetime. Folding it in would make it account-scoped, so switching accounts would flip the user's grid/list choice, and a revision bump would silently reset it.
Scope lives in the key, and that DELETED machinery
Design Review's second round accepted the shape but priced its cost: a single record tagged with one scope meant a real position under account A overwriting account B's, so a user switching accounts lost their folder every time -- while the trivial per-account-key alternative retains both for free. That was a fair charge, and the fix turned out to remove code rather than add it.
Moving the scope into the key deleted: the
scopefield in the record, the scope comparison inparseViewState, thescope-mismatchoutcome and its whole reporting tier, the "is this record even mine" branch inresolveViewStateWritealong with the stored-record read and parse it needed to answer that, and the second half of the snapshot comparison in the hook.What is left is smaller and says more.
resolveViewStateWriteis now two cases -- defaults remove, anything else writes -- and takes no stored record at all, because a record under this key can only ever be this scope's. The hook compares ONE thing, the key, and that single comparison now covers a namespace granted late, an appId change, AND a scope change, because all three are the same fact: the state in hand belongs to a different address than the one being asked for.scopeis DATA, unlikename, so it is percent-encoded rather than validated and refused. Encoding escapes:, which is what stops one scope value from forging a segment boundary into another scope's key -- there is a test fora:band forowner/repo.The cost is one small record per scope the user actually visits, which is the contract the key shape implies and the spec now states:
scopenames a subject the user chooses among, such as an account, not a per-item id.The exported surface is only what has a consumer
First Principles found that seven exports had zero non-test consumers, and it was right on the facts -- I checked, and
readPersistedString, the precedent I expected to lean on, turns out to have a real consumer inRemoteCrewPanel.tsx, so it does not support test-only exports at all.pickDeclared,isDefaultState,serializeViewRecord,parseViewState,resolveViewStateWriteandviewStateKeyare now module-internal, along with the three types that only described their signatures. What remains exported isuseAppViewState,isViewStringand the two declaration types -- four names, all with real consumers.Unexporting the key derivation made the tests stronger rather than weaker. They now assert the key as a LITERAL string (
kc:app:aws-control:view:drive:<account-id>), which states the on-disk contract; recomputing it with the function under test would have let a dropped segment agree with itself.The record-level properties they used to assert directly are now exercised through the hook, which is the surface a caller actually has: seed storage, mount, and read back what was restored and what was written. No property lost coverage, and re-running the mutation matrix against the moved tests is what caught two of them going soft, described above.
That refactor also removed a redundant filter.
canonicalStateused to callpickDeclaredon its merge, but every path into it already carries filtered values --setViewfilters a patch andparseViewStateaccepts only guard-passing fields -- so the call could not change any output, which is precisely why no test could observe it.The spec moves with the code
docs/system-specs/features/app-sdk-durable-jobs-and-view-state.mdsaid the platform provides no view-state contract, and nameduseAppViewStatespecifically as a hook that does not exist. This PR falsifies that, and AGENTS.md requires the spec to change in the same commit, so it does.The correction keeps a distinction the old text collapsed. URL-backed view position is still NOT an App SDK contract -- this PR adds no URL synchronisation -- so that section is retitled rather than deleted, and it now says why the two are complementary: a record restores a position on a fresh visit, which a URL cannot do, and a URL makes a position shareable, which a record cannot do. A new section describes what the record does cover.
The barrel sentence stayed true as written and was kept: the barrel really does export no
useAppViewState, because the store is path-imported by design. The spec now says that explicitly rather than leaving it to imply the hook does not exist.scripts/docs-lint.shpasses (260 files scanned).An in-blob revision is new here, deliberately
There is no in-blob versioning precedent in this frontend. The two shapes already present are a version in the KEY (
kc:file-explorer:state:v2) and a tolerant partial parse that never versions at all (issue-radar'sloadUiStateinlib/format.ts).Neither fits a store the host owns on behalf of many apps. A version in the key makes the host's key FORMAT part of what every app has to know, and pushes a migration into each app's key string. One field inside the record lets the store decide centrally and keeps the key a stable address.
What is reported, and what is not
The design left this open. Split three ways, because the cases differ in what a reader can act on:
console.debug. Only reachable after someone deliberately changed the schema, and it answers "why did everyone's position reset" immediately.console.warn, deduped per key. This is the only genuine fault -- something wrote garbage under a host-owned key -- and a warning repeated on every remount drowns out real signal.Also
safeRemoveItemjoinssafeGetItemandsafeSetIteminsafeStorage.ts. It exposes behaviour the module already relied on internally (reclaimSpacecallsremoveItem); what was missing was a way for a caller outside that file to delete a key without hand-rolling the try/catch.Nothing was added to the
app-sdk/index.tsbarrel. The store is imported by path, following the precedent recorded there foruseComposerDraft: the barrel is the surface third-party apps resolve, held in agreement with the vendor stub, so a name on it is published, and publishing later is additive while un-publishing is a break. This store is builtin-only by construction, so in a third-party app it would be a hook that never persists anything.4. What tests we did
265 tests pass across 10 files: the three new suites, both pre-existing aws-control suites unchanged, plus
chatProtocolBoundary,appSdkProviderSplit,safeStorageandBuiltinAppRoute.tsc -band eslint are clean.Decision logic is kept in pure functions, following the shape of
apps/overlaySlots.ts:viewStateKey,pickDeclared,parseViewState,isDefaultState,serializeViewRecord, andresolveViewStateWriteare all testable without mounting a tree.Two behaviours worth naming, because the tests found them rather than confirming them:
isDefaultState({})was false. A field that is simply not set IS its default, or the store would treat "nothing set" as a position worth storing.A third was found by mutation testing rather than by a failing test: the snapshot was keyed on scope alone, so a namespace granted LATE (AppHost forwards an installed app's origin from data, so the value above a continuously-mounted page can change) would leave the first namespace-less answer in place forever and the page would never restore. An appId change had the same shape as the scope bug -- it would write one app's position into another app's namespace. Both now re-read.
Mutation checks
Twelve guards, each deleted, each failing a named test, measured in one pass against a committed baseline with a clean working tree, verified clean and still at that commit afterwards. A thirteenth is disclosed below as NOT observable, rather than counted.
That discipline is load-bearing rather than tidiness. An earlier pass reverted mutations with
git checkout --while the same file still held unstaged edits, so the revert discarded three edits toviewState.tsand the following run measured a partially-reverted tree. A mutation result measured that way is not evidence, so those results were discarded rather than reported: the harness now commits a baseline first and every result below comes from that tree. The lost edits are thekeyre-read described above, which is also why it appears in the table.useTrustedAppIdgate replaced by raw identitypickDeclaredif (existing) return scopedin #8403'sAppApiProviderOne guard is deliberately NOT in that table. The loop that serializes declared fields in a
stable order cannot be shown to matter today: with a single declared field, an ordered
serialization and a plain
JSON.stringifyof the merged object are byte-identical, sodeleting the loop breaks nothing and no honest test can be written for it. It is kept
because it is the last structural point at which an undeclared field cannot reach storage,
and because byte-stability is what
isDefaultStatecompares -- both of which start tomatter at the second declared field. Reporting it as unobservable rather than padding the
count.
The last row crosses the stack boundary on purpose. The rule it targets is that identity is published only when there is none already in context, which stops a nested
AppApiProviderfrom replacing a builtin page's identity with itsexternaldefault. Without it,useTrustedAppId()returns null and this store falls back to defaults forever with no error anywhere -- the page just quietly stops remembering. The base pins that identity is not shadowed; this pins the consequence for the store, stated as the property rather than as whose code it is, so the assertion stays valid wherever the rule ends up living.One nuance about the write filter, recorded because it changes what the guard actually is. Deleting the
pickDeclaredcall inside canonicalization alone changes nothing: canonicalization only ever emits declared field names, so an undeclared field cannot reach storage even without it. The property is STRUCTURAL there rather than resting on that call. What is guard-dependent, and what the table's canonicalization and per-field rows pin, is removing the canonicalization entirely and validating each field's value.Also worth recording: the first version of the late-namespace test passed for the wrong reason. Re-rendering from
<Probe>to<Provider><Probe></Provider>changes the consumer's position in the tree, so React remounts it and the initializer re-runs -- the test passed whether or not the re-read existed. It now flipsoriginon a provider that stays mounted, which is a re-read and not a remount. Mutation testing is what surfaced that, and the lesson generalizes past the Suspense case: any rerender that moves a component's position manufactures a remount and can hide an initializer bug.Warm-mount check
Because a first-render assertion can be masked by Suspense, there is an explicit test that mounts the consumer under a real
React.lazyboundary twice: cold, where the child suspends before rendering, and then WARM, where the module is already resolved and parent and child render in one pass. The warm pass is the one nothing can hide behind, and the assertion is on the first recorded render rather than the settled DOM.Served-bundle evidence
Built, synced into the directory the pod serves, and grepped over HTTP from the pod on an isolated port. Grepping string literals rather than identifier names, because minification mangles identifiers --
useAppViewStatehas zero hits in the served bundle and proves nothing either way.From
assets/AwsControlPage-DvGgAtrP.js(84924 bytes) fetched over HTTP:kc:app::view:not a valid key segmentwas written under another revisioncould not be read; mounting with defaultsuseAppViewState(identifier, negative control)resolveViewStateWrite(identifier, negative control)KEY_SEGMENT_RE(identifier, negative control)kc:app:aws-control:view"(the OLD per-app key shape)The composed key appears in the bundle as
kc:app:${e}:view:${t}, so both segments are interpolated rather than one being dropped.The served file is md5-identical to the freshly built one, so this is not a stale bundle.
Full-window screenshots
Attached. Captured from an isolated pod (port 7944, zero AWS accounts, zero prior sessions, onboarding wizard dismissed server-side). Token minted via
kirocrew pod exec <pod> -- token, which bypasses the OWNER_UNPROVEN port-ownership refusal thepod tokenpath hits from an agent sandbox.The folder restore flow itself (descend, leave, return, land in subfolder) cannot be shown because the Drive pane is gated on
drive?.exists, which requires a connected AWS account with an S3 bucket. That behaviour is covered by the 265 tests and 12 mutation guards above.5. Any other suggestions on the work
Deliberately out of scope, listed so the boundaries are visible:
pathis not added to the URL. Section 3's "URL first" preference is right for shareability, but a fresh visit to/aws-controlfrom the sidebar carries no params, so the URL alone cannot solve the reported symptom. Complementary, and its own change.