Skip to content

fix(plugin-dashboard): the auto-refresh interval reads its handler through a ref, not a memoised identity (objectui#11004) - #11059

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-11004-autorefresh-handler-ref
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-11004-autorefresh-handler-ref

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11004
Clause-②: no

What changed

useDashboardAutoRefresh (the one timer behind DashboardGridLayout and DashboardRenderer) armed its interval in an effect keyed on [seconds, onRefresh, handleRefresh], and handleRefresh is a useCallback result. That is the shape AGENTS.md commandment #10 bans (objectui#8640): if React discards the memo, the new function identity clears the interval and sets it again, and the phase restarts. The card's premise holds on origin/main at 30f912a0d: the dependency list and its own warning comment were still there.

  • The latest host handler now lives in one ref (onRefreshRef), updated in a dependency-free effect. This is the house shape, the same one used by surfaceDeepLinkChannel.ts and the studio-design panels.
  • A module-scope runRefresh(handler, setRefreshing) holds the run: call the handler, then show the 600ms indicator. The manual handleRefresh (a useCallback with an empty list, kept only for cost) and the interval callback both call it, and both read the handler through the ref.
  • The interval effect is keyed on the two VALUES it reads: [seconds, wired], where wired = Boolean(onRefresh).

Zone 2, item 2, was decided from commandment #10: onRefresh leaves the list too. It is a caller-supplied identity, which is the cross-boundary instance of the same class the commandment names ("a prop, a hook return, a context"). The ref still meets the handler-swap case, because the CURRENT handler is the one called. The one behaviour change is the intended one: when the host passes a new handler identity at the same period, the phase is kept instead of restarted. No in-tree host passes a changing onRefresh today. DashboardWithConfig only passes its prop through, and the console's DashboardView wires none.

The new case

The new case is keeps its phase when a handler identity changes at an equal period (objectui#11004), inside the existing per-surface describe.each, so it runs once per surface. It runs as two steps on one 30s schedule:

  1. At 20s it forces a memo discard, with the same host handler and the same period. The discard uses a module-level react proxy, the same technique as providerCtxIdentity.discarded.test.tsx in packages/permissions, and is inert unless a case arms it. It then asserts the run still lands at 30s. A control asserts that the discard really reached the hook: handleRefresh comes back as a different function. Without that control, a proxy that patched nothing would read green.
  2. At 50s the host swaps in a new handler at the same period. The case asserts the NEW handler runs at 60s and the old one does not run again.

All 22 earlier cases are untouched and green, the equal-period phase case and the handler-swap case among them.

Evidence, all at HEAD 8fa76e0ed

  • pnpm exec vitest run packages/plugin-dashboard/src/__tests__/dashboardAutoRefreshTimer.test.tsx: base tree Tests 22 passed (22), this branch Tests 24 passed (24).
  • Reverse verification (fix committed first, hook reverted to 30f912a0d, restored with git checkout HEAD, blob equal to HEAD and git diff HEAD empty): Tests 2 failed | 22 passed (24). The two failures are the new case, one per surface, at a discarded memo re-armed the interval. The controls passed, so the discard did reach the hook.
  • Mutation legs, through ablation-replace.mjs (anchor hit 1 to 0, blob changed, then restored to the HEAD blob with git diff HEAD empty):
    • onRefresh added back to the interval list: 2 failed | 22 passed (24) at a new host handler identity re-armed the interval. Step 1 passed, so step 2 is load-bearing on its own.
    • handleRefresh added back (even as a ref-reading, empty-list callback): 2 failed | 22 passed (24) at a discarded memo re-armed the interval.
  • pnpm exec vitest run packages/plugin-dashboard/: Test Files 151 passed (151), Tests 1379 passed | 6 skipped (1385).
  • pnpm --filter @object-ui/plugin-dashboard type-check: exit 0, after building the package's dependency closure (--filter '@object-ui/plugin-dashboard^...' run build, exit 0). tsc -p tsconfig.test.json --listFiles includes the changed test file.
  • pnpm exec eslint --format json on the two changed source files: 2 files, 0 errors, 0 warnings. react-hooks/exhaustive-deps accepts [seconds, wired]. This is a declared narrowing: the config enables no type-aware linting (no parserOptions.project or projectService), and no local rule in eslint-rules/ reads another file, so this diff cannot move a verdict on an untouched file. The package-wide eslint . belongs to CI.
  • node scripts/check-*.mjs, each exit 0: changeset-presence, control-bytes, vi-mock-specifiers, vi-mock-inherit, vi-mock-override-shape, new-cross-file-line-citations (0 new citation(s)), changeset-no-major, changeset-claims, pending-changeset-literals, test-path-roots.
  • Nothing in scripts/, eslint-rules/ or .github/ enforces commandment [WIP] Enhance every detail of the designer #10. A grep for it returned zero hits, with a positive control that did hit, which matches the commandment's own "Nothing enforces this rule".

Changeset: .changeset/11004-autorefresh-handler-ref.md, '@object-ui/plugin-dashboard': patch.

Acceptance notes

  • Observation, not fixed here: the console's DashboardView mounts DashboardRenderer with no onRefresh, so an authored refreshIntervalSeconds never starts a timer in the console. The Studio metadata form still offers the field (its zh label is "自动刷新"). This was read from the JSX, not measured in a running console. It is handed to the seat in the report.

Generated by Claude Code

…rough a ref

useDashboardAutoRefresh keyed its interval effect on the handleRefresh
useCallback identity and on the host's onRefresh identity (AGENTS.md #10).
Either identity changing at an equal period cleared and re-armed the
interval, restarting its phase. The effect now depends on the period and on
whether a handler is wired; the handler is read through a ref at run time,
so the current onRefresh is still the one called.

The timer pin adds one case per surface that forces a memo discard
mid-period (module-level react proxy, as in the permissions pins) and then
swaps the host handler mid-period, asserting the phase holds both times.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 330 chunks) 3106.4 KB 3149.4 KB
Main entry chunk (gzip) 149.3 KB 350 KB
Entry file index-_nOq1om_.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.72KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.17KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 558.92KB 133.97KB
core (index.js) 9.94KB 3.94KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 227.99KB 63.22KB
fields (index.js) 261.01KB 66.28KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.35KB 9.18KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.32KB 11.09KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.58KB 4.90KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.25KB 2.17KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 52.02KB 14.87KB
plugin-charts (index.js) 84.09KB 22.93KB
plugin-chatbot (index.js) 198.22KB 46.97KB
plugin-dashboard (index.js) 137.68KB 36.71KB
plugin-designer (index.js) 215.78KB 44.42KB
plugin-detail (index.js) 233.54KB 61.80KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 165.60KB 42.64KB
plugin-gantt (index.js) 170.35KB 42.19KB
plugin-grid (index.js) 228.33KB 62.59KB
plugin-kanban (index.js) 48.43KB 15.11KB
plugin-list (index.js) 115.86KB 28.64KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.04KB 12.21KB
plugin-timeline (index.js) 31.07KB 9.15KB
plugin-tree (index.js) 11.21KB 3.89KB
plugin-view (index.js) 89.75KB 22.52KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 119.16KB 39.05KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 7.50KB 3.05KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.16KB 2.71KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 19.75KB 6.81KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 3.83KB 1.49KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 3.19KB 1.62KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.26KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.30KB 6.99KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8fa76e0edbf828acf76b72fe70aeef50d539156a
Local-runs: none

Inputs: card #11004 (body, triage 5874791933, claim 5884804354, os-dev-report 5885185646), PR #11059 (body, 3-file list, net diff against main), and the head's check-runs (filter=latest). Governing text: objectui AGENTS.md commandment #10 as it reads at this head.

① Derived judgments

  1. Interval effect keyed on [seconds, wired] — RIGHT. Both are the primitives the effect reads (the authored period; wired = Boolean(onRefresh)). No useMemo / useCallback identity remains in any dependency list, Map key or reference comparison inside the hook, which is the whole of what [WIP] Enhance every detail of the designer #10 bans. handleRefresh stays a useCallback with an empty list for cost only, and both surfaces consume it solely as the onClick of the "Refresh All" button (DashboardGridLayout, DashboardRenderer at this head), never as a dependency — so the instance is closed on both sides of the seam and no new one is opened downstream.
  2. Latest handler read through onRefreshRef, assigned in a dependency-free effect, with the run moved to a module-scope runRefresh(handler, setRefreshing) shared by the timer callback and the manual handler — RIGHT. The run calls the CURRENT onRefresh at fire time, so the pin's handler-swap case holds without re-arming; setRefreshing is a state setter, whose identity React does guarantee, so its absence from the list is correct.
  3. onRefresh (a prop) leaves the list too — RIGHT, and inside the card. [WIP] Enhance every detail of the designer #10 names "a prop, a hook return, a context" as the cross-boundary instances of the same class, and triage's direction ("read the latest handler through a ref, and drop the identity from the dependency list"; a case that "the interval is not re-armed when the handler's identity changes at an equal period") asks for exactly this. The one behaviour change — a new host handler identity at an equal period now keeps the phase — is the intended one. No in-tree host relied on the old re-arm: DashboardWithConfig passes onRefresh through unchanged and the console mounts DashboardRenderer without one.
  4. A wired change (handler removed, then re-wired) still clears and re-arms — RIGHT: an unwired surface must arm nothing, and the earlier pin "arms no interval without an onRefresh host handler" still covers it.
  5. Accept set and public surface: nothing moves. useDashboardAutoRefresh is not exported by src/index.tsx; the DashboardAutoRefresh interface is byte-identical; both surfaces' props are untouched; the read of refreshIntervalSeconds is unchanged (absent, 0 and negative still mean off; the retired refreshInterval spelling is still unread, per #0.1).
  6. The pin — RIGHT. One new case per surface inside the existing describe.each, as the file's own header demands (both mount points driven on their own, never collapsed). The forced discard is the module-level react proxy of providerCtxIdentity.discarded.test.tsx in packages/permissions (verified at this head: same memoProxy epoch shape, same stated reason that vi.spyOn on the frozen module namespace cannot patch the hooks' own bindings). The proxy patches useMemo and useCallback only, not useEffect — matching what React reserves the right to discard — is armed before the mount and disarmed in afterEach, so the 22 earlier cases run on plain React. The control (latestHandleRefresh() differs after discardNow() plus a rerender) makes the case falsifiable; without it a proxy that patched nothing would read green. Step 1 asserts the run still lands at 30s, not 50s; step 2 asserts the new host handler runs at 60s, not 80s, and the old one never again. The dev's reverse verification and two mutation legs (each step red on its own) are consistent with the diff as written; none was re-run here.
  7. Adjacent memo in a mounted surface: DashboardGridLayout's widgetsSignature useMemo feeds a useEffect dependency, but it returns a JSON.stringify string, a primitive compared by value, so the armed proxy's discards neither re-run that effect nor perturb the new case. Not a [WIP] Enhance every detail of the designer #10 instance; noted only because the proxy discards every memo in the mounted tree.
  8. Text discipline: English-only comments and changeset (#-1); no path:line citation anywhere in the diff ([WIP] Update documentation for project #11; Line Citation Gate success); the header's "pins the first three points" is a description, not a copied count (完善设计器的每一个细节 #9); the changeset cites by symbol and test name; no model identifier in the PR title, body, changeset or comments.

② Semver level

.changeset/11004-autorefresh-handler-ref.md declares '@object-ui/plugin-dashboard': patch — RIGHT. A src/ file of a released package changed, so a changeset is required, and one is present (Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Changeset Claim Re-read, Changeset Overwrite Report all success on the head). Patch is the fitting level: a bug fix inside one hook, no export, prop, schema key or contract change, and no major, as the fixed-group rule requires.

Clause-②: no — RIGHT. No accept set, schema, export or contract moves; an interval's re-arm condition moves inside one hook, and the PR body's own Clause-②: no line matches the claim's.

③ Boundary flags

Dev flags (the os-dev-report's deviations), each answered:

  1. pr_create refused once by write-pace, then one in-window retry — process only; the diff is unaffected. Answered.
  2. "Add one case" read as one case per surface (22 to 24 instances) — right; the file's header forbids collapsing the surfaces into one hook-level case. Answered.
  3. One case carrying both readings (the card body's forced memo discard, then triage's host handler swap) as two named steps on one schedule — acceptable. The steps are sequential, so step 2 runs only once step 1 passes, but each assertion message names its own step, and the mutation legs show each step load-bearing alone. Answered.
  4. The ref-update mechanism, useRef plus a dependency-free useEffect — right. [WIP] Enhance every detail of the designer #10 prescribes none; the shape is the one surfaceDeepLinkChannel.ts uses for onRequestRef (verified at this head) and a commit-phase assignment rather than a render-phase write. Answered.
  5. Commit trailers: the head commit ends with the model-free Co-authored-by and Claude-Session: pair that AGENTS.md requires, and no model identifier lands in the PR title, body, changeset or code comments. Answered.

open_questions: none filed; nothing to answer.

Out-of-scope observation (the console's DashboardView mounts DashboardRenderer with no onRefresh, so an authored refreshIntervalSeconds never starts a timer in the console while Studio still offers the field): correctly left out of this diff, which stays on #11004's surface. It has no carrier yet — escalated to the seat as the candidate card the report grades it (class c); a static reading, not a browser measurement.

Check-runs on the head (filter=latest; 11 were still running when this review opened, so the verdict waited for them — all 43 completed by 07:00Z, none pending): 40 success, 3 skipped (the two coverage jobs and dependabot), 0 failure, 0 cancelled. The eight Test shards, Test (dist pins), Type Check, Lint, Build & E2E, Inert vi.mock Specifier Check, Line Citation Gate, Control Byte Scan and the five changeset gates are all success — those conclusions are the derived gate verdicts this record adopts.

Implemented-by: claude/issue-11004-autorefresh-handler-ref
Reviewed-by: session_011p7ikEivgXefNDaE5S5Uec

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 07:12
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 6466a09 Sep 29, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11004-autorefresh-handler-ref branch September 29, 2026 07:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants