Skip to content

feat(pages): support client-only page rendering - #212

Merged
cpojer merged 2 commits into
mainfrom
pages-ssr-opt-out
Jun 8, 2026
Merged

cpojer merged 2 commits into
mainfrom
pages-ssr-opt-out

Conversation

@TheAlexLichter

@TheAlexLichter TheAlexLichter commented May 25, 2026 •

Copy link
Copy Markdown
Member

Right now you can't opt out from SSR per page when using Void.

This PR changes that, adding a per-page export const ssr = false support for pages routes.
When set, it renders the client-only shells with loader data, head output, assets, and if using React, dev preamble support

Context

@netlify

netlify Bot commented May 25, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for staging-void-cloud ready!

Name Link
🔨 Latest commit a71dc17
🔍 Latest deploy log https://app.netlify.com/projects/staging-void-cloud/deploys/6a145b2aa3622e00083371cb
😎 Deploy Preview https://deploy-preview-212--staging-void-cloud.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@TheAlexLichter
TheAlexLichter requested a review from cpojer May 25, 2026 03:55
@TheAlexLichter
TheAlexLichter marked this pull request as ready for review May 25, 2026 03:55
@cpojer

cpojer commented May 25, 2026

Copy link
Copy Markdown
Member

If you wanna do one more pass, I think this is worth sending to your agent:

I’d keep the public API simple, but normalize it into explicit internal states immediately.

  Public API:

  export const ssr = false;
  export const prerender = true;
  export const revalidate = 60;

  Internal model:

  type ExplicitBoolean = true | false | undefined;

  type PageRenderMode = 'server' | 'client' | 'island';
  type PagePrerenderMode = 'auto' | 'force' | 'disabled';

  type PageDefinition = {
    // source facts
    island?: boolean;
    ssr?: ExplicitBoolean;
    prerender?: ExplicitBoolean;

    // normalized decisions
    renderMode: PageRenderMode;
    prerenderMode: PagePrerenderMode;
    shouldPrerender: boolean;
  };

  Then make one resolver own the rules:

  function resolvePageRendering(page, options): ResolvedPageRendering {
    if (page.island && page.ssr === false) {
      throw new Error(`pages: ssr = false is not valid for island page
  '${page.componentPath}'.`);
    }

    const renderMode =
      page.island ? 'island' :
      page.ssr === false ? 'client' :
      'server';

    const prerenderMode =
      page.prerender === true ? 'force' :
      page.prerender === false ? 'disabled' :
      'auto';

    const shouldPrerender =
      prerenderMode === 'force' ||
      (prerenderMode === 'auto' && inferAutoPrerender(page, options));

    return { renderMode, prerenderMode, shouldPrerender };
  }

  I would not pass island?, ssr? as positional booleans into handlePageGet. I’d pass a single
  options object:

  handlePageGet(..., {
    renderMode: page.renderMode,
  });

  and inside protocol:

  if (options.renderMode === 'client') {
    return renderClientOnlyPage(...);
  }

  if (options.renderMode === 'island') {
    // existing island behavior
  }

  return renderWithAssetTags(...);

  The important part: preserve explicit false. Right now prerender = false is consumed during
  scan and then disappears. I’d keep it as data until normalization, because “unset”, “forced
  true”, and “forced false” are different states.

  I’d also document/define these combinations:

  ssr unset/true + prerender unset   -> normal SSR, auto prerender rules
  ssr false      + prerender unset   -> client shell rendered on request
  ssr false      + prerender true    -> prerendered client shell with embedded loader data
  ssr false      + prerender false   -> client shell only on request, never prerender
  island page    + ssr false         -> error

  For ssr = true, I’d parse it but treat it as explicit server rendering. That gives you a
  future escape hatch if a global default ever becomes client-only:

  ssr: 'auto' | 'server' | 'client'

  or normalized from booleans:

  undefined -> 'auto'
  true      -> 'server'
  false     -> 'client'

  So my preferred final internal shape would be:

  type SsrMode = 'auto' | 'server' | 'client';
  type PrerenderMode = 'auto' | 'force' | 'disabled';

  type ResolvedPageOptions = {
    renderMode: 'server' | 'client' | 'island';
    prerenderMode: PrerenderMode;
    shouldPrerender: boolean;
  };

  That keeps the user-facing API familiar, but stops the implementation from accumulating
  ambiguous optional booleans.

@TheAlexLichter

Copy link
Copy Markdown
Member Author

Forwarding 😛

@TheAlexLichter
TheAlexLichter requested a review from cpojer June 7, 2026 21:32
@cpojer
cpojer merged commit 2b8f01e into main Jun 8, 2026
9 checks passed
@cpojer
cpojer deleted the pages-ssr-opt-out branch June 8, 2026 02:25
cpojer pushed a commit that referenced this pull request Sep 11, 2026
#212)

* feat(api): add configurable workflow_path column to github_connections

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plan): SDD plan for configurable deploy-workflow path (0010 addendum)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): persist workflow_path in github connection create/update helpers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): accept + validate workflow_path on github connect/update routes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): pin OIDC deploy token to per-connection workflow_path

Move the workflow-path gate from a hardcoded global constant to the selected
connection's workflow_path, checked after connection selection. Preserves the
anti-spoof workflowPathForRepo behavior and the deploy-oracle protection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(api): cover per-connection workflow_path pin + setter validation

OIDC: custom path mints, custom path rejects the scaffolded default, and
two projects on one repo pin their own workflows. Also adds repositoryId to
the pre-existing other.yml/spoof rejection tests so they reach the (now
post-lookup) workflow gate instead of short-circuiting at connection lookup.
Routes: POST/PATCH accept a valid path, default when omitted, and 400 on a
path outside .github/workflows/ or containing '..'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): make workflow_path change atomic (revoke in-flight builds + lock-recheck)

workflow_path is the OIDC anti-oracle gate, but was not treated like the
sibling trust-config fields branch/executor: a change neither revoked
in-flight builds nor was re-validated under the connection lock. Two gaps,
both asymmetric with branch:

- github.ts PATCH now terminalizes in-flight github_actions builds when
  workflow_path changes (their deploy token stays valid while active, so a
  build authorized under the OLD pin could keep publishing). Container builds
  don't use the workflow gate and are left running.
- createGithubActionsBuildWithinLimit re-reads workflow_path under the same
  FOR UPDATE lock it already uses for branch and rejects with a new
  'workflow-mismatch' outcome when the token's workflow no longer matches the
  current pin — closing the check(snapshot)->lock TOCTOU. auth-oidc passes the
  matched workflow path in and maps the outcome to 409.

Regression tests: PATCH cancels in-flight github_actions builds (container
untouched); createGithubActionsBuildWithinLimit returns workflow-mismatch when
the pin diverged. Addresses the high-severity finding from the adversarial
review of the configurable deploy-workflow-path feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plan): note atomic workflow_path change (0010 addendum)

Record the post-review semantics: a workflow_path pin change revokes in-flight
github_actions builds and is re-validated under the connection lock (mirroring
branch/executor), and the CLI fails closed if the server doesn't echo the pin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump framework submodule to configurable deploy-workflow-path CLI

Points the platform at the framework branch tip carrying the `--workflow`
flag (#245: ee64d04, ef845e8) rebased on framework main.
Advances the pin from the current main pin (ab38b96) to that tip, keeping
API workflow_path support + matching CLI in lockstep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: format drizzle 0029 migration metadata (oxfmt)

The drizzle-kit-generated 0029 snapshot + journal weren't oxfmt-formatted,
which the full-repo `vp fmt --check` in CI rejects. Formatting only — the
JSON data is byte-for-byte identical when canonicalized (verified via jq).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: re-point framework submodule to #245 merge (de1d581)

#245 squash-merged to framework main as de1d581. Move the
pin off the now-deleted PR-branch tip (ef845e8) onto the merge commit on
main. Tree-identical to ef845e8 (verified via git diff), so no code change
from what CI validated — this only makes the pin a real main commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants